Compare commits

..

13 Commits

Author SHA1 Message Date
Flaminel
c43936ce81 Remove testing download path (#436) 2026-02-13 11:52:53 +02:00
Flaminel
f35eb0c922 Add full message for logs on the UI (#435) 2026-02-13 02:05:39 +02:00
Flaminel
b2b0626b44 Add external url for notifications (#434) 2026-02-13 02:04:27 +02:00
Flaminel
40f108d7ca Add app status setting to general settings (#433) 2026-02-12 23:12:26 +02:00
Flaminel
6570f74b7e Fix Deluge failing on empty password (#432) 2026-02-12 22:29:55 +02:00
Flaminel
16f216cf84 Add Gotify notification provider (#420) 2026-02-12 18:17:09 +02:00
Flaminel
69551edeff Revamp UI (#429) 2026-02-12 17:51:30 +02:00
Flaminel
7192796e89 Update frontend packages (#422) 2026-01-14 14:17:04 +02:00
Flaminel
1d1ee7972f Use CDN to deliver logos (#421) 2026-01-14 13:51:25 +02:00
Flaminel
8bd6b86018 Add Discord notification provider (#417) 2026-01-13 18:53:40 +02:00
Flaminel
6abb542271 Fix Servarr version dropdown (#414) 2026-01-11 02:33:16 +02:00
Flaminel
2aceae3078 Fix package lock file being out of sync (#411) 2026-01-09 00:05:34 +02:00
Flaminel
65b200a68e Fix MacOS build (#409) 2026-01-07 02:57:52 +02:00
435 changed files with 24692 additions and 40259 deletions

View File

@@ -21,12 +21,12 @@ jobs:
matrix:
include:
- arch: Intel
runner: macos-13
runner: macos-15-intel
runtime: osx-x64
min_os_version: "10.15"
artifact_suffix: intel
- arch: ARM
runner: macos-14
runner: macos-15
runtime: osx-arm64
min_os_version: "11.0"
artifact_suffix: arm64

350
CLAUDE.md Normal file
View File

@@ -0,0 +1,350 @@
# Cleanuparr - Claude AI Rules
## 🚨 Critical Guidelines
**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
## 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, and µTorrent. 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
- Orphaned download cleanup with cross-seed support
- Support for multiple notification providers (Discord, etc.)
## Architecture & Tech Stack
### Backend
- **.NET 10.0** (C#) with ASP.NET Core
- **Architecture**: Clean Architecture pattern
- `Cleanuparr.Domain` - Domain models and business logic
- `Cleanuparr.Application` - Application services and use cases
- `Cleanuparr.Infrastructure` - External integrations (*arr apps, download clients)
- `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`
- **Key Libraries**:
- MassTransit (messaging)
- Quartz.NET (scheduling)
- Serilog (logging)
- SignalR (real-time communication)
### Frontend
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **UI**: Custom glassmorphism design system (no external UI frameworks)
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
- **Design System**: 3-layer SCSS (`_variables``_tokens``_themes`), dark/light themes
- **State Management**: @ngrx/signals (Angular signals-based)
- **Real-time Updates**: SignalR (@microsoft/signalr)
- **PWA**: Service Worker support enabled
### Documentation
- **Docusaurus** (TypeScript-based static site)
- Hosted at https://cleanuparr.github.io/Cleanuparr/
### 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
```
You need a GitHub PAT with `read:packages` permission.
### Running the Backend
```bash
cd code/backend
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
```
API runs at http://localhost:5000
### Running the Frontend
```bash
cd code/frontend
npm install
npm start
```
UI runs at http://localhost:4200
### Running Tests
```bash
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:
```bash
# Data migrations
make migrate-data name=YourMigrationName
# Events migrations
make migrate-events name=YourMigrationName
```
Example:
```bash
make migrate-data name=AddDownloadClientConfig
make migrate-events name=AddStrikeEvents
```
## Common Development Workflows
### Adding a New *arr Application Integration
1. Add integration in `Cleanuparr.Infrastructure/Arr/`
2. Update domain models in `Cleanuparr.Domain/`
3. Create/update services in `Cleanuparr.Application/`
4. Add API endpoints in `Cleanuparr.Api/`
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/`
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/`
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
- 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

View File

@@ -1,5 +1,5 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching

View File

@@ -1,9 +1,11 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
namespace Cleanuparr.Api.DependencyInjection;
@@ -18,6 +20,8 @@ public static class NotificationsDI
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<ITelegramProxy, TelegramProxy>()
.AddScoped<IDiscordProxy, DiscordProxy>()
.AddScoped<IGotifyProxy, GotifyProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

@@ -21,11 +21,14 @@ public sealed record ArrInstanceRequest
[Required]
public required float Version { get; init; }
public string? ExternalUrl { get; init; }
public ArrInstance ToEntity(Guid configId) => new()
{
Enabled = Enabled,
Name = Name,
Url = new Uri(Url),
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
ApiKey = ApiKey,
ArrConfigId = configId,
Version = Version,
@@ -36,6 +39,7 @@ public sealed record ArrInstanceRequest
instance.Enabled = Enabled;
instance.Name = Name;
instance.Url = new Uri(Url);
instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null;
instance.ApiKey = ApiKey;
instance.Version = Version;
}

View File

@@ -24,6 +24,8 @@ public sealed record CreateDownloadClientRequest
public string? UrlBase { get; init; }
public Uri? ExternalUrl { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -47,5 +49,6 @@ public sealed record CreateDownloadClientRequest
Username = Username,
Password = Password,
UrlBase = UrlBase,
ExternalUrl = ExternalUrl,
};
}

View File

@@ -24,6 +24,8 @@ public sealed record UpdateDownloadClientRequest
public string? UrlBase { get; init; }
public Uri? ExternalUrl { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -47,5 +49,6 @@ public sealed record UpdateDownloadClientRequest
Username = Username,
Password = Password,
UrlBase = UrlBase,
ExternalUrl = ExternalUrl,
};
}

View File

@@ -24,6 +24,8 @@ public sealed record UpdateGeneralConfigRequest
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
public bool StatusCheckEnabled { get; init; } = true;
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; init; } = [];
@@ -39,6 +41,7 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
existingConfig.SearchEnabled = SearchEnabled;
existingConfig.SearchDelay = SearchDelay;
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateDiscordProviderRequest : CreateNotificationProviderRequestBase
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateGotifyProviderRequest : CreateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestDiscordProviderRequest
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestGotifyProviderRequest
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateDiscordProviderRequest : UpdateNotificationProviderRequestBase
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateGotifyProviderRequest : UpdateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -5,8 +5,10 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.AspNetCore.Mvc;
@@ -50,6 +52,8 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -76,6 +80,8 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => p.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => p.DiscordConfiguration ?? new object(),
NotificationProviderType.Gotify => p.GotifyConfiguration ?? new object(),
_ => new object()
}
})
@@ -694,6 +700,8 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
@@ -926,11 +934,201 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => provider.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => provider.DiscordConfiguration ?? new object(),
NotificationProviderType.Gotify => provider.GotifyConfiguration ?? new object(),
_ => new object()
}
};
}
[HttpPost("discord")]
public async Task<IActionResult> CreateDiscordProvider([FromBody] CreateDiscordProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var discordConfig = new DiscordConfig
{
WebhookUrl = newProvider.WebhookUrl,
Username = newProvider.Username,
AvatarUrl = newProvider.AvatarUrl
};
discordConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Discord,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
DiscordConfiguration = discordConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Discord provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("discord/{id:guid}")]
public async Task<IActionResult> UpdateDiscordProvider(Guid id, [FromBody] UpdateDiscordProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.DiscordConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Discord);
if (existingProvider == null)
{
return NotFound($"Discord provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var discordConfig = new DiscordConfig
{
WebhookUrl = updatedProvider.WebhookUrl,
Username = updatedProvider.Username,
AvatarUrl = updatedProvider.AvatarUrl
};
if (existingProvider.DiscordConfiguration != null)
{
discordConfig = discordConfig with { Id = existingProvider.DiscordConfiguration.Id };
}
discordConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
DiscordConfiguration = discordConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Discord provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("discord/test")]
public async Task<IActionResult> TestDiscordProvider([FromBody] TestDiscordProviderRequest testRequest)
{
try
{
var discordConfig = new DiscordConfig
{
WebhookUrl = testRequest.WebhookUrl,
Username = testRequest.Username,
AvatarUrl = testRequest.AvatarUrl
};
discordConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Discord,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = discordConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (DiscordException ex)
{
_logger.LogWarning(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
[HttpPost("pushover")]
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
{
@@ -1128,4 +1326,192 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
[HttpPost("gotify")]
public async Task<IActionResult> CreateGotifyProvider([FromBody] CreateGotifyProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = newProvider.ServerUrl,
ApplicationToken = newProvider.ApplicationToken,
Priority = newProvider.Priority
};
gotifyConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Gotify,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
GotifyConfiguration = gotifyConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Gotify provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("gotify/{id:guid}")]
public async Task<IActionResult> UpdateGotifyProvider(Guid id, [FromBody] UpdateGotifyProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.GotifyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Gotify);
if (existingProvider == null)
{
return NotFound($"Gotify provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = updatedProvider.ServerUrl,
ApplicationToken = updatedProvider.ApplicationToken,
Priority = updatedProvider.Priority
};
if (existingProvider.GotifyConfiguration != null)
{
gotifyConfig = gotifyConfig with { Id = existingProvider.GotifyConfiguration.Id };
}
gotifyConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
GotifyConfiguration = gotifyConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Gotify provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("gotify/test")]
public async Task<IActionResult> TestGotifyProvider([FromBody] TestGotifyProviderRequest testRequest)
{
try
{
var gotifyConfig = new GotifyConfig
{
ServerUrl = testRequest.ServerUrl,
ApplicationToken = testRequest.ApplicationToken,
Priority = testRequest.Priority
};
gotifyConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Gotify,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = gotifyConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (GotifyException ex)
{
_logger.LogWarning(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
}

View File

@@ -7,4 +7,6 @@ public enum NotificationProviderType
Ntfy,
Pushover,
Telegram,
Discord,
Gotify,
}

View File

@@ -339,8 +339,8 @@ public class EventPublisherTests : IDisposable
public async Task PublishQueueItemDeleted_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set("downloadName", "Test Download");
ContextProvider.Set("hash", "abc123");
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "abc123");
// Act
await _publisher.PublishQueueItemDeleted(removeFromClient: true, DeleteReason.Stalled);
@@ -360,8 +360,8 @@ public class EventPublisherTests : IDisposable
public async Task PublishQueueItemDeleted_SendsNotification()
{
// Arrange
ContextProvider.Set("downloadName", "Test Download");
ContextProvider.Set("hash", "abc123");
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "abc123");
// Act
await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport);
@@ -378,8 +378,8 @@ public class EventPublisherTests : IDisposable
public async Task PublishDownloadCleaned_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set("downloadName", "Cleaned Download");
ContextProvider.Set("hash", "def456");
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Cleaned Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "def456");
// Act
await _publisher.PublishDownloadCleaned(
@@ -404,8 +404,8 @@ public class EventPublisherTests : IDisposable
public async Task PublishDownloadCleaned_SendsNotification()
{
// Arrange
ContextProvider.Set("downloadName", "Test");
ContextProvider.Set("hash", "xyz");
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
var ratio = 1.5;
var seedingTime = TimeSpan.FromHours(24);
@@ -428,7 +428,7 @@ public class EventPublisherTests : IDisposable
{
// Arrange
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:8989"));
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://localhost:8989"));
// Act
await _publisher.PublishSearchNotTriggered("abc123", "Test Item");
@@ -452,7 +452,7 @@ public class EventPublisherTests : IDisposable
{
// Arrange
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Radarr);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:7878"));
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://localhost:7878"));
// Act
await _publisher.PublishRecurringItem("hash123", "Recurring Item", 5);
@@ -475,8 +475,8 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set("downloadName", "Category Test");
ContextProvider.Set("hash", "cat123");
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Category Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "cat123");
// Act
await _publisher.PublishCategoryChanged("oldCat", "newCat", isTag: false);
@@ -493,8 +493,8 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_WithTag_SavesCorrectMessage()
{
// Arrange
ContextProvider.Set("downloadName", "Tag Test");
ContextProvider.Set("hash", "tag123");
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Tag Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "tag123");
// Act
await _publisher.PublishCategoryChanged("", "cleanuperr-done", isTag: true);
@@ -509,8 +509,8 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_SendsNotification()
{
// Arrange
ContextProvider.Set("downloadName", "Test");
ContextProvider.Set("hash", "xyz");
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
// Act
await _publisher.PublishCategoryChanged("old", "new", isTag: true);

View File

@@ -0,0 +1,315 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Discord;
public class DiscordProxyTests
{
private readonly Mock<ILogger<DiscordProxy>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public DiscordProxyTests()
{
_loggerMock = new Mock<ILogger<DiscordProxy>>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private DiscordProxy CreateProxy()
{
return new DiscordProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
}
private static DiscordPayload CreatePayload()
{
return new DiscordPayload
{
Embeds = new List<DiscordEmbed>
{
new()
{
Title = "Test Title",
Description = "Test Description",
Color = 0x28a745
}
}
};
}
private static DiscordConfig CreateConfig()
{
return new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "Test Bot",
AvatarUrl = "https://example.com/avatar.png"
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_BuildsCorrectUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123/abc"
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://discord.com/api/webhooks/123/abc", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_LogsTraceWithContent()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region SendNotification Error Tests
[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task SendNotification_WhenUnauthorized_ThrowsDiscordExceptionWithInvalidWebhook(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("invalid or unauthorized", ex.Message);
}
[Fact]
public async Task SendNotification_When404_ThrowsDiscordExceptionWithNotFound()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.NotFound);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("not found", ex.Message);
}
[Fact]
public async Task SendNotification_When429_ThrowsDiscordExceptionWithRateLimited()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse((HttpStatusCode)429);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("rate limited", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
public async Task SendNotification_WhenServiceUnavailable_ThrowsDiscordException(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsDiscordException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsDiscordException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -0,0 +1,329 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Gotify;
public class GotifyProxyTests
{
private readonly Mock<ILogger<GotifyProxy>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public GotifyProxyTests()
{
_loggerMock = new Mock<ILogger<GotifyProxy>>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private GotifyProxy CreateProxy()
{
return new GotifyProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
}
private static GotifyPayload CreatePayload()
{
return new GotifyPayload
{
Title = "Test Title",
Message = "Test Message",
Priority = 5
};
}
private static GotifyConfig CreateConfig()
{
return new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token",
Priority = 5
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_BuildsCorrectUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "my-token",
Priority = 5
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_TrimsTrailingSlashFromServerUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com/",
ApplicationToken = "my-token",
Priority = 5
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_LogsTraceWithContent()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region SendNotification Error Tests
[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task SendNotification_WhenUnauthorized_ThrowsGotifyExceptionWithInvalidToken(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("invalid or unauthorized", ex.Message);
}
[Fact]
public async Task SendNotification_When404_ThrowsGotifyExceptionWithNotFound()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.NotFound);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("not found", ex.Message);
}
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
public async Task SendNotification_WhenServiceUnavailable_ThrowsGotifyException(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsGotifyException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsGotifyException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -37,7 +37,7 @@ public class NotifiarrProxyTests
return new NotifiarrPayload
{
Notification = new NotifiarrNotification { Update = false },
Discord = new Discord
Discord = new NotifiarrDiscord
{
Color = "#FF0000",
Text = new Text { Title = "Test", Content = "Test content" },

View File

@@ -358,12 +358,9 @@ public class NotificationConfigurationServiceTests : IDisposable
#region Provider Type Mapping Tests
[Theory]
[InlineData(NotificationProviderType.Notifiarr)]
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
[MemberData(nameof(NotificationProviderTypes))]
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
{
// Arrange
@@ -383,11 +380,7 @@ public class NotificationConfigurationServiceTests : IDisposable
}
[Theory]
[InlineData(NotificationProviderType.Notifiarr)]
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
[MemberData(nameof(NotificationProviderTypes))]
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
{
// Arrange
@@ -420,6 +413,8 @@ public class NotificationConfigurationServiceTests : IDisposable
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
NotificationProviderType.Telegram => CreateTelegramConfig(name, isEnabled),
NotificationProviderType.Discord => CreateDiscordConfig(name, isEnabled),
NotificationProviderType.Gotify => CreateGotifyConfig(name, isEnabled),
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
};
}
@@ -549,5 +544,60 @@ public class NotificationConfigurationServiceTests : IDisposable
};
}
private static NotificationConfig CreateDiscordConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Discord,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
DiscordConfiguration = new DiscordConfig
{
Id = Guid.NewGuid(),
WebhookUrl = "http://localhost:8000",
AvatarUrl = "https://example.com/avatar.png",
Username = "test_username",
}
};
}
private static NotificationConfig CreateGotifyConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Gotify,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
GotifyConfiguration = new GotifyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://localhost:8000",
ApplicationToken = "test_application_token",
}
};
}
#endregion
public static IEnumerable<object[]> NotificationProviderTypes =>
[
..Enum.GetValues<NotificationProviderType>()
.Cast<Object>()
.Select(x => new[] { x })
.ToList()
];
}

View File

@@ -1,6 +1,8 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
@@ -21,6 +23,8 @@ public class NotificationProviderFactoryTests
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
private readonly Mock<ITelegramProxy> _telegramProxyMock;
private readonly Mock<IDiscordProxy> _discordProxyMock;
private readonly Mock<IGotifyProxy> _gotifyProxyMock;
private readonly IServiceProvider _serviceProvider;
private readonly NotificationProviderFactory _factory;
@@ -32,6 +36,8 @@ public class NotificationProviderFactoryTests
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
_pushoverProxyMock = new Mock<IPushoverProxy>();
_telegramProxyMock = new Mock<ITelegramProxy>();
_discordProxyMock = new Mock<IDiscordProxy>();
_gotifyProxyMock = new Mock<IGotifyProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
@@ -40,6 +46,8 @@ public class NotificationProviderFactoryTests
services.AddSingleton(_notifiarrProxyMock.Object);
services.AddSingleton(_pushoverProxyMock.Object);
services.AddSingleton(_telegramProxyMock.Object);
services.AddSingleton(_discordProxyMock.Object);
services.AddSingleton(_gotifyProxyMock.Object);
_serviceProvider = services.BuildServiceProvider();
_factory = new NotificationProviderFactory(_serviceProvider);
@@ -194,6 +202,61 @@ public class NotificationProviderFactoryTests
Assert.Equal(NotificationProviderType.Telegram, provider.Type);
}
[Fact]
public void CreateProvider_DiscordType_CreatesDiscordProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestDiscord",
Type = NotificationProviderType.Discord,
IsEnabled = true,
Configuration = new DiscordConfig
{
Id = Guid.NewGuid(),
WebhookUrl = "test-webhook-url",
AvatarUrl = "test-avatar-url",
Username = "test-username",
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<DiscordProvider>(provider);
Assert.Equal("TestDiscord", provider.Name);
Assert.Equal(NotificationProviderType.Discord, provider.Type);
}
[Fact]
public void CreateProvider_GotifyType_CreatesGotifyProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestGotify",
Type = NotificationProviderType.Gotify,
IsEnabled = true,
Configuration = new GotifyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "test-server-url",
ApplicationToken = "test-application-token",
}
};
var provider = _factory.CreateProvider(config);
Assert.NotNull(provider);
Assert.IsType<GotifyProvider>(provider);
Assert.Equal("TestGotify", provider.Name);
Assert.Equal(NotificationProviderType.Gotify, provider.Type);
}
[Fact]
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
{

View File

@@ -59,14 +59,15 @@ public class NotificationPublisherTests
ContextProvider.Set(nameof(QueueRecord), record);
ContextProvider.Set(nameof(InstanceType), instanceType);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://sonarr.local"));
ContextProvider.Set("version", 1f);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://sonarr.local"));
ContextProvider.Set(ContextProvider.Keys.Version, 1f);
}
private void SetupDownloadCleanerContext()
{
ContextProvider.Set("downloadName", "Test Download");
ContextProvider.Set("hash", "HASH123");
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, new Uri("http://downloadclient.local"));
ContextProvider.Set(ContextProvider.Keys.Hash, "HASH123");
}
#region Constructor Tests
@@ -204,6 +205,28 @@ public class NotificationPublisherTests
Times.Once);
}
[Fact]
public async Task NotifyStrike_WithoutExternalUrl_UsesInternalUrlInNotification()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.Data["Url"] == "http://sonarr.local/")), Times.Once);
}
#endregion
#region NotifyQueueItemDeleted Tests
@@ -312,6 +335,30 @@ public class NotificationPublisherTests
Assert.Equal("25", capturedContext.Data["Seeding hours"]); // Rounds to 25
}
[Fact]
public async Task NotifyDownloadCleaned_WithDownloadClientUrl_IncludesUrlInNotification()
{
// Arrange
SetupDownloadCleanerContext();
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, new Uri("https://qbit.external.com"));
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyDownloadCleaned(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.Data.ContainsKey("Url") &&
c.Data["Url"] == "https://qbit.external.com/")), Times.Once);
}
#endregion
#region NotifyCategoryChanged Tests

View File

@@ -5,6 +5,7 @@ using Cleanuparr.Domain.Entities.AppStatus;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
@@ -20,6 +21,7 @@ public class AppStatusRefreshServiceTests : IDisposable
private readonly AppStatusSnapshot _snapshot;
private readonly JsonSerializerOptions _jsonOptions;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly Mock<IServiceScopeFactory> _scopeFactoryMock;
private AppStatusRefreshService? _service;
public AppStatusRefreshServiceTests()
@@ -30,6 +32,7 @@ public class AppStatusRefreshServiceTests : IDisposable
_snapshot = new AppStatusSnapshot();
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
_httpHandlerMock = new Mock<HttpMessageHandler>();
_scopeFactoryMock = new Mock<IServiceScopeFactory>();
// Setup hub context
var clientsMock = new Mock<IHubClients>();
@@ -50,7 +53,8 @@ public class AppStatusRefreshServiceTests : IDisposable
_hubContextMock.Object,
_httpClientFactoryMock.Object,
_snapshot,
_jsonOptions);
_jsonOptions,
_scopeFactoryMock.Object);
return _service;
}

View File

@@ -60,7 +60,7 @@ public class StrikerTests : IDisposable
// Set up required context for recurring item events and FailedImport strikes
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
ContextProvider.Set("ArrInstanceUrl", new Uri("http://localhost:8989"));
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://localhost:8989"));
ContextProvider.Set(new QueueRecord
{
Title = "Test Item",

View File

@@ -104,7 +104,7 @@
// {
// public ShouldCleanDownloadTests(DownloadServiceFixture fixture) : base(fixture)
// {
// ContextProvider.Set("downloadName", "test-download");
// ContextProvider.Set(ContextProvider.Keys.DownloadName, "test-download");
// }
//
// [Fact]

View File

@@ -145,8 +145,8 @@ public class EventPublisher : IEventPublisher
public async Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>("downloadName") ?? "Unknown";
string hash = ContextProvider.Get<string>("hash") ?? "Unknown";
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
await PublishAsync(
@@ -165,8 +165,8 @@ public class EventPublisher : IEventPublisher
public async Task PublishDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>("downloadName");
string hash = ContextProvider.Get<string>("hash");
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
await PublishAsync(
@@ -185,8 +185,8 @@ public class EventPublisher : IEventPublisher
public async Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>("downloadName");
string hash = ContextProvider.Get<string>("hash");
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
await PublishAsync(
@@ -205,7 +205,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishRecurringItem(string hash, string itemName, int strikeCount)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
// Publish the event
await PublishManualAsync(
@@ -221,7 +221,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishSearchNotTriggered(string hash, string itemName)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
await PublishManualAsync(
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",

View File

@@ -24,4 +24,6 @@ public record ArrInstanceDto
[Required]
public required string ApiKey { get; init; }
public string? ExternalUrl { get; init; }
}

View File

@@ -33,4 +33,13 @@ public static class ContextProvider
string key = typeof(T).Name ?? throw new Exception("Type name is null");
return Get<T>(key);
}
public static class Keys
{
public const string Version = "version";
public const string DownloadName = "downloadName";
public const string Hash = "hash";
public const string DownloadClientUrl = "downloadClientUrl";
public const string ArrInstanceUrl = "arrInstanceUrl";
}
}

View File

@@ -38,7 +38,7 @@ public sealed class DelugeClient
public async Task<bool> LoginAsync()
{
return await SendRequest<bool>("auth.login", _config.Password);
return await SendRequest<bool>("auth.login", _config.Password ?? "");
}
public async Task<bool> IsConnected()

View File

@@ -88,42 +88,18 @@ public partial class DelugeService : DownloadService, IDelugeService
public override async Task<HealthCheckResult> HealthCheckAsync()
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
bool hasCredentials = !string.IsNullOrEmpty(_downloadClientConfig.Username) ||
!string.IsNullOrEmpty(_downloadClientConfig.Password);
await _client.LoginAsync();
if (hasCredentials)
if (!await _client.IsConnected() && !await _client.Connect())
{
// If credentials are provided, we must be able to login and connect for the service to be healthy
await _client.LoginAsync();
if (!await _client.IsConnected() && !await _client.Connect())
{
throw new Exception("Deluge WebUI is not connected to the daemon");
}
_logger.LogDebug("Health check: Successfully logged in to Deluge client {clientId}", _downloadClientConfig.Id);
}
else
{
// If no credentials, test basic connectivity to the web UI
// We'll try a simple HTTP request to verify the service is running
if (_httpClient == null)
{
throw new InvalidOperationException("HTTP client is not initialized");
}
var response = await _httpClient.GetAsync("/");
if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.Unauthorized)
{
throw new Exception($"Service returned status code: {response.StatusCode}");
}
_logger.LogDebug("Health check: Successfully connected to Deluge client {clientId}", _downloadClientConfig.Id);
throw new Exception("Deluge WebUI is not connected to the daemon");
}
_logger.LogDebug("Health check: Successfully logged in to Deluge client {clientId}", _downloadClientConfig.Id);
stopwatch.Stop();
return new HealthCheckResult
@@ -135,9 +111,9 @@ public partial class DelugeService : DownloadService, IDelugeService
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogWarning(ex, "Health check failed for Deluge client {clientId}", _downloadClientConfig.Id);
return new HealthCheckResult
{
IsHealthy = false,

View File

@@ -72,8 +72,9 @@ public partial class DelugeService
continue;
}
ContextProvider.Set("downloadName", torrent.Name);
ContextProvider.Set("hash", torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
DelugeContents? contents;
try

View File

@@ -124,8 +124,9 @@ public abstract class DownloadService : IDownloadService
continue;
}
ContextProvider.Set("downloadName", torrent.Name);
ContextProvider.Set("hash", torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds);
SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, category);
@@ -219,7 +220,7 @@ public abstract class DownloadService : IDownloadService
return false;
}
string downloadName = ContextProvider.Get<string>("downloadName");
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
TimeSpan minSeedingTime = TimeSpan.FromHours(category.MinSeedTime);
if (category.MinSeedTime > 0 && seedingTime < minSeedingTime)
@@ -245,7 +246,7 @@ public abstract class DownloadService : IDownloadService
return false;
}
string downloadName = ContextProvider.Get<string>("downloadName");
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
TimeSpan maxSeedingTime = TimeSpan.FromHours(category.MaxSeedTime);
if (category.MaxSeedTime > 0 && seedingTime < maxSeedingTime)

View File

@@ -104,8 +104,9 @@ public partial class QBitService
continue;
}
ContextProvider.Set("downloadName", torrent.Name);
ContextProvider.Set("hash", torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
bool hasHardlinks = false;
bool hasErrors = false;

View File

@@ -66,8 +66,9 @@ public partial class TransmissionService
continue;
}
ContextProvider.Set("downloadName", torrent.Name);
ContextProvider.Set("hash", torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
if (torrent.Info.Files is null || torrent.Info.FileStats is null)
{

View File

@@ -62,8 +62,9 @@ public partial class UTorrentService
continue;
}
ContextProvider.Set("downloadName", torrent.Name);
ContextProvider.Set("hash", torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(torrent.Hash);

View File

@@ -51,12 +51,12 @@ public sealed class QueueItemRemover : IQueueItemRemover
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
// Set context for EventPublisher
ContextProvider.Set("downloadName", request.Record.Title);
ContextProvider.Set("hash", request.Record.DownloadId);
ContextProvider.Set(ContextProvider.Keys.DownloadName, request.Record.Title);
ContextProvider.Set(ContextProvider.Keys.Hash, request.Record.DownloadId);
ContextProvider.Set(nameof(QueueRecord), request.Record);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), request.Instance.Url);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, request.Instance.ExternalUrl ?? request.Instance.Url);
ContextProvider.Set(nameof(InstanceType), request.InstanceType);
ContextProvider.Set("version", request.Instance.Version);
ContextProvider.Set(ContextProvider.Keys.Version, request.Instance.Version);
// Use the new centralized EventPublisher method
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);

View File

@@ -100,9 +100,9 @@ public sealed class MalwareBlocker : GenericHandler
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
// push to context
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), instance.Url);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalUrl ?? instance.Url);
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);
ContextProvider.Set("version", instance.Version);
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();

View File

@@ -90,9 +90,9 @@ public sealed class QueueCleaner : GenericHandler
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
// push to context
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), instance.Url);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalUrl ?? instance.Url);
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);
ContextProvider.Set("version", instance.Version);
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
bool hasEnabledTorrentClients = ContextProvider

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public class DiscordException : Exception
{
public DiscordException(string message) : base(message)
{
}
public DiscordException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,59 @@
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public class DiscordPayload
{
public string? Username { get; set; }
[JsonProperty("avatar_url")]
public string? AvatarUrl { get; set; }
public List<DiscordEmbed> Embeds { get; set; } = new();
}
public class DiscordEmbed
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Color { get; set; }
public DiscordThumbnail? Thumbnail { get; set; }
public DiscordImage? Image { get; set; }
public List<DiscordField> Fields { get; set; } = new();
public DiscordFooter? Footer { get; set; }
public string? Timestamp { get; set; }
}
public class DiscordField
{
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public bool Inline { get; set; }
}
public class DiscordThumbnail
{
public string Url { get; set; } = string.Empty;
}
public class DiscordImage
{
public string Url { get; set; } = string.Empty;
}
public class DiscordFooter
{
public string Text { get; set; } = string.Empty;
[JsonProperty("icon_url")]
public string? IconUrl { get; set; }
}

View File

@@ -0,0 +1,93 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public sealed class DiscordProvider : NotificationProviderBase<DiscordConfig>
{
private readonly IDiscordProxy _proxy;
public DiscordProvider(
string name,
NotificationProviderType type,
DiscordConfig config,
IDiscordProxy proxy)
: base(name, type, config)
{
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
private DiscordPayload BuildPayload(NotificationContext context)
{
var color = context.Severity switch
{
EventSeverity.Warning => 0xf0ad4e, // Orange/yellow
EventSeverity.Important => 0xbb2124, // Red
_ => 0x28a745 // Green
};
var embed = new DiscordEmbed
{
Title = context.Title,
Description = context.Description,
Color = color,
Thumbnail = new DiscordThumbnail { Url = Constants.LogoUrl },
Fields = BuildFields(context),
Footer = new DiscordFooter
{
Text = "Cleanuparr",
IconUrl = Constants.LogoUrl
},
Timestamp = DateTime.UtcNow.ToString("o")
};
if (context.Image != null)
{
embed.Image = new DiscordImage { Url = context.Image.ToString() };
}
var payload = new DiscordPayload
{
Embeds = new List<DiscordEmbed> { embed }
};
// Apply username override if configured
if (!string.IsNullOrWhiteSpace(Config.Username))
{
payload.Username = Config.Username;
}
// Apply avatar override if configured
if (!string.IsNullOrWhiteSpace(Config.AvatarUrl))
{
payload.AvatarUrl = Config.AvatarUrl;
}
return payload;
}
private List<DiscordField> BuildFields(NotificationContext context)
{
var fields = new List<DiscordField>();
foreach ((string key, string value) in context.Data)
{
fields.Add(new DiscordField
{
Name = key,
Value = value,
Inline = false
});
}
return fields;
}
}

View File

@@ -0,0 +1,64 @@
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public sealed class DiscordProxy : IDiscordProxy
{
private readonly ILogger<DiscordProxy> _logger;
private readonly HttpClient _httpClient;
public DiscordProxy(ILogger<DiscordProxy> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task SendNotification(DiscordPayload payload, DiscordConfig config)
{
try
{
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
_logger.LogTrace("sending notification to Discord: {content}", content);
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, config.WebhookUrl);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new DiscordException("unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 401:
case 403:
throw new DiscordException("unable to send notification | webhook URL is invalid or unauthorized");
case 404:
throw new DiscordException("unable to send notification | webhook not found");
case 429:
throw new DiscordException("unable to send notification | rate limited, please try again later", exception);
case 502:
case 503:
case 504:
throw new DiscordException("unable to send notification | Discord service unavailable", exception);
default:
throw new DiscordException("unable to send notification", exception);
}
}
}
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public interface IDiscordProxy
{
Task SendNotification(DiscordPayload payload, DiscordConfig config);
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public class GotifyException : Exception
{
public GotifyException(string message) : base(message)
{
}
public GotifyException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,25 @@
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public class GotifyPayload
{
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public int Priority { get; set; } = 5;
public GotifyExtras? Extras { get; set; }
}
public class GotifyExtras
{
[JsonProperty("client::display")]
public GotifyClientDisplay? ClientDisplay { get; set; }
}
public class GotifyClientDisplay
{
public string? ContentType { get; set; }
}

View File

@@ -0,0 +1,62 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public sealed class GotifyProvider : NotificationProviderBase<GotifyConfig>
{
private readonly IGotifyProxy _proxy;
public GotifyProvider(
string name,
NotificationProviderType type,
GotifyConfig config,
IGotifyProxy proxy)
: base(name, type, config)
{
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
private GotifyPayload BuildPayload(NotificationContext context)
{
var message = BuildMessage(context);
return new GotifyPayload
{
Title = context.Title,
Message = message,
Priority = Config.Priority,
Extras = new GotifyExtras
{
ClientDisplay = new GotifyClientDisplay
{
ContentType = "text/markdown"
}
}
};
}
private string BuildMessage(NotificationContext context)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(context.Description))
{
lines.Add(context.Description);
}
foreach ((string key, string value) in context.Data)
{
lines.Add($"**{key}:** {value}");
}
return string.Join("\n\n", lines);
}
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public sealed class GotifyProxy : IGotifyProxy
{
private readonly ILogger<GotifyProxy> _logger;
private readonly HttpClient _httpClient;
public GotifyProxy(ILogger<GotifyProxy> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task SendNotification(GotifyPayload payload, GotifyConfig config)
{
try
{
string baseUrl = config.ServerUrl.TrimEnd('/');
string url = $"{baseUrl}/message?token={config.ApplicationToken}";
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
_logger.LogTrace("sending notification to Gotify: {content}", content);
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new GotifyException("unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 401:
case 403:
throw new GotifyException("unable to send notification | application token is invalid or unauthorized");
case 404:
throw new GotifyException("unable to send notification | Gotify server not found");
case 502:
case 503:
case 504:
throw new GotifyException("unable to send notification | Gotify service unavailable", exception);
default:
throw new GotifyException("unable to send notification", exception);
}
}
}
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public interface IGotifyProxy
{
Task SendNotification(GotifyPayload payload, GotifyConfig config);
}

View File

@@ -3,7 +3,7 @@
public class NotifiarrPayload
{
public NotifiarrNotification Notification { get; set; } = new NotifiarrNotification();
public Discord Discord { get; set; }
public NotifiarrDiscord Discord { get; set; }
}
public class NotifiarrNotification
@@ -13,7 +13,7 @@ public class NotifiarrNotification
public int? Event { get; set; }
}
public class Discord
public class NotifiarrDiscord
{
public string Color { get; set; } = string.Empty;
public Ping Ping { get; set; }

View File

@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
@@ -34,8 +35,6 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
_ => "28a745"
};
const string logo = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
return new NotifiarrPayload
{
Discord = new()
@@ -44,7 +43,7 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
Text = new()
{
Title = context.Title,
Icon = logo,
Icon = Constants.LogoUrl,
Description = context.Description,
Fields = BuildFields(context)
},
@@ -54,7 +53,7 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
},
Images = new()
{
Thumbnail = new Uri(logo),
Thumbnail = new Uri(Constants.LogoUrl),
Image = context.Image
}
}

View File

@@ -88,6 +88,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -139,6 +141,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
NotificationProviderType.Ntfy => config.NtfyConfiguration,
NotificationProviderType.Pushover => config.PushoverConfiguration,
NotificationProviderType.Telegram => config.TelegramConfiguration,
NotificationProviderType.Discord => config.DiscordConfiguration,
NotificationProviderType.Gotify => config.GotifyConfiguration,
_ => throw new ArgumentOutOfRangeException(nameof(config), $"Config type for provider type {config.Type.ToString()} is not registered")
};

View File

@@ -1,11 +1,13 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
@@ -29,6 +31,8 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
NotificationProviderType.Ntfy => CreateNtfyProvider(config),
NotificationProviderType.Pushover => CreatePushoverProvider(config),
NotificationProviderType.Telegram => CreateTelegramProvider(config),
NotificationProviderType.Discord => CreateDiscordProvider(config),
NotificationProviderType.Gotify => CreateGotifyProvider(config),
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
};
}
@@ -73,4 +77,20 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
return new TelegramProvider(config.Name, config.Type, telegramConfig, proxy);
}
private INotificationProvider CreateDiscordProvider(NotificationProviderDto config)
{
var discordConfig = (DiscordConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IDiscordProxy>();
return new DiscordProvider(config.Name, config.Type, discordConfig, proxy);
}
private INotificationProvider CreateGotifyProvider(NotificationProviderDto config)
{
var gotifyConfig = (GotifyConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IGotifyProxy>();
return new GotifyProvider(config.Name, config.Type, gotifyConfig, proxy);
}
}

View File

@@ -4,7 +4,6 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
@@ -120,8 +119,8 @@ public class NotificationPublisher : INotificationPublisher
{
var record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceVersion = (float)ContextProvider.Get<object>("version");
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var instanceVersion = (float)ContextProvider.Get<object>(ContextProvider.Keys.Version);
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
var imageUrl = GetImageFromContext(record, instanceType, instanceVersion);
NotificationContext context = new()
@@ -154,8 +153,8 @@ public class NotificationPublisher : INotificationPublisher
{
var record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceVersion = (float)ContextProvider.Get<object>("version");
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var instanceVersion = (float)ContextProvider.Get<object>(ContextProvider.Keys.Version);
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
var imageUrl = GetImageFromContext(record, instanceType, instanceVersion);
return new NotificationContext
@@ -178,8 +177,9 @@ public class NotificationPublisher : INotificationPublisher
private static NotificationContext BuildDownloadCleanedContext(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
{
var downloadName = ContextProvider.Get<string>("downloadName");
var hash = ContextProvider.Get<string>("hash");
var downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
var hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
var clientUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.DownloadClientUrl);
return new NotificationContext
{
@@ -189,6 +189,7 @@ public class NotificationPublisher : INotificationPublisher
Severity = EventSeverity.Important,
Data = new Dictionary<string, string>
{
["Url"] = clientUrl.ToString(),
["Hash"] = hash.ToLowerInvariant(),
["Category"] = categoryName.ToLowerInvariant(),
["Ratio"] = ratio.ToString(CultureInfo.InvariantCulture),
@@ -199,7 +200,8 @@ public class NotificationPublisher : INotificationPublisher
private NotificationContext BuildCategoryChangedContext(string oldCategory, string newCategory, bool isTag)
{
string downloadName = ContextProvider.Get<string>("downloadName");
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
Uri clientUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.DownloadClientUrl);
NotificationContext context = new()
{
@@ -209,10 +211,11 @@ public class NotificationPublisher : INotificationPublisher
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>
{
["hash"] = ContextProvider.Get<string>("hash").ToLowerInvariant()
["Url"] = clientUrl.ToString(),
["hash"] = ContextProvider.Get<string>(ContextProvider.Keys.Hash).ToLowerInvariant(),
}
};
if (isTag)
{
context.Data.Add("Tag", newCategory);

View File

@@ -68,7 +68,7 @@ public sealed class NotificationService
["Test time"] = DateTime.UtcNow.ToString("o"),
["Provider type"] = providerConfig.Type.ToString(),
},
Image = new Uri("https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/Logo/256.png")
Image = new Uri("https://cdn.jsdelivr.net/gh/Cleanuparr/Cleanuparr@main/Logo/256.png")
};
try

View File

@@ -2,11 +2,14 @@ using System.Text.Json;
using Cleanuparr.Domain.Entities.AppStatus;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Cleanuparr.Infrastructure.Services;
@@ -17,18 +20,20 @@ public sealed class AppStatusRefreshService : BackgroundService
private readonly IHttpClientFactory _httpClientFactory;
private readonly AppStatusSnapshot _snapshot;
private readonly JsonSerializerOptions _jsonOptions;
private readonly IServiceScopeFactory _scopeFactory;
private AppStatus? _lastBroadcast;
private static readonly Uri StatusUri = new("https://cleanuparr-status.pages.dev/status.json");
private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(10);
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(5);
public AppStatusRefreshService(
ILogger<AppStatusRefreshService> logger,
IHubContext<AppHub> hubContext,
IHttpClientFactory httpClientFactory,
AppStatusSnapshot snapshot,
JsonSerializerOptions jsonOptions
JsonSerializerOptions jsonOptions,
IServiceScopeFactory scopeFactory
)
{
_logger = logger;
@@ -36,6 +41,7 @@ public sealed class AppStatusRefreshService : BackgroundService
_httpClientFactory = httpClientFactory;
_snapshot = snapshot;
_jsonOptions = jsonOptions;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -66,13 +72,22 @@ public sealed class AppStatusRefreshService : BackgroundService
private async Task RefreshAsync(CancellationToken cancellationToken)
{
if (!await IsStatusCheckEnabledAsync(cancellationToken))
{
if (_snapshot.UpdateLatestVersion(null, out var status))
{
await BroadcastAsync(status, cancellationToken);
}
return;
}
try
{
using var client = _httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
using var response = await client.GetAsync(StatusUri, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var payload = await JsonSerializer.DeserializeAsync<Status>(stream, _jsonOptions, cancellationToken: cancellationToken);
var latest = payload?.Version;
@@ -96,6 +111,29 @@ public sealed class AppStatusRefreshService : BackgroundService
}
}
private async Task<bool> IsStatusCheckEnabledAsync(CancellationToken cancellationToken)
{
try
{
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
await using DataContext dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
GeneralConfig config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
return config.StatusCheckEnabled;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read StatusCheckEnabled setting, proceeding with status check");
return true;
}
}
private async Task BroadcastAsync(AppStatus status, CancellationToken cancellationToken)
{
if (status.Equals(_lastBroadcast))

View File

@@ -0,0 +1,149 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification;
public sealed class DiscordConfigTests
{
#region IsValid Tests
[Fact]
public void IsValid_WithValidWebhookUrl_ReturnsTrue()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij"
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullWebhookUrl_ReturnsFalse(string? webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl ?? string.Empty
};
config.IsValid().ShouldBeFalse();
}
[Fact]
public void IsValid_WithOptionalFieldsEmpty_ReturnsTrue()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "",
AvatarUrl = ""
};
config.IsValid().ShouldBeTrue();
}
#endregion
#region Validate Tests
[Fact]
public void Validate_WithValidConfig_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "Test Bot",
AvatarUrl = "https://example.com/avatar.png"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullWebhookUrl_ThrowsValidationException(string? webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl ?? string.Empty
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData("https://example.com/webhook")]
[InlineData("http://discord.com/api/webhooks/123/abc")]
[InlineData("not-a-url")]
[InlineData("https://discord.com/api/something-else")]
public void Validate_WithInvalidWebhookUrl_ThrowsValidationException(string webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid Discord webhook URL");
}
[Theory]
[InlineData("https://discord.com/api/webhooks/123456789/abcdefghij")]
[InlineData("https://discordapp.com/api/webhooks/123456789/abcdefghij")]
public void Validate_WithValidWebhookUrls_DoesNotThrow(string webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WithInvalidAvatarUrl_ThrowsValidationException()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = "not-a-valid-url"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid URL");
}
[Fact]
public void Validate_WithValidAvatarUrl_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = "https://example.com/avatar.png"
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WithEmptyAvatarUrl_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = ""
};
Should.NotThrow(() => config.Validate());
}
#endregion
}

View File

@@ -0,0 +1,189 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification;
public sealed class GotifyConfigTests
{
#region IsValid Tests
[Fact]
public void IsValid_WithValidConfig_ReturnsTrue()
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token"
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullServerUrl_ReturnsFalse(string? serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl ?? string.Empty,
ApplicationToken = "test-token"
};
config.IsValid().ShouldBeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullApplicationToken_ReturnsFalse(string? token)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = token ?? string.Empty
};
config.IsValid().ShouldBeFalse();
}
#endregion
#region Validate Tests
[Fact]
public void Validate_WithValidConfig_DoesNotThrow()
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token",
Priority = 5
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullServerUrl_ThrowsValidationException(string? serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl ?? string.Empty,
ApplicationToken = "test-token"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData("not-a-url")]
[InlineData("ftp://gotify.example.com")]
[InlineData("invalid://scheme")]
public void Validate_WithInvalidServerUrl_ThrowsValidationException(string serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl,
ApplicationToken = "test-token"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid HTTP or HTTPS URL");
}
[Theory]
[InlineData("https://gotify.example.com")]
[InlineData("http://localhost:8080")]
[InlineData("https://gotify.local:8443/")]
public void Validate_WithValidServerUrls_DoesNotThrow(string serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl,
ApplicationToken = "test-token"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullApplicationToken_ThrowsValidationException(string? token)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = token ?? string.Empty
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData(-1)]
[InlineData(11)]
[InlineData(100)]
public void Validate_WithInvalidPriority_ThrowsValidationException(int priority)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-token",
Priority = priority
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("Priority");
}
[Theory]
[InlineData(0)]
[InlineData(5)]
[InlineData(10)]
public void Validate_WithValidPriority_DoesNotThrow(int priority)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-token",
Priority = priority
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Default Values Tests
[Fact]
public void NewConfig_HasDefaultPriorityOf5()
{
var config = new GotifyConfig();
config.Priority.ShouldBe(5);
}
[Fact]
public void NewConfig_HasEmptyStringsForRequiredFields()
{
var config = new GotifyConfig();
config.ServerUrl.ShouldBe(string.Empty);
config.ApplicationToken.ShouldBe(string.Empty);
}
#endregion
}

View File

@@ -56,6 +56,10 @@ public class DataContext : DbContext
public DbSet<TelegramConfig> TelegramConfigs { get; set; }
public DbSet<DiscordConfig> DiscordConfigs { get; set; }
public DbSet<GotifyConfig> GotifyConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
@@ -156,6 +160,16 @@ public class DataContext : DbContext
.HasForeignKey<TelegramConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.DiscordConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<DiscordConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.GotifyConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<GotifyConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => p.Name).IsUnique();
});

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddDiscord : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "discord_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
webhook_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
username = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
avatar_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_discord_configs", x => x.id);
table.ForeignKey(
name: "fk_discord_configs_notification_configs_notification_config_id",
column: x => x.notification_config_id,
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_discord_configs_notification_config_id",
table: "discord_configs",
column: "notification_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "discord_configs");
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddGotify : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "gotify_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
server_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
application_token = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
priority = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_gotify_configs", x => x.id);
table.ForeignKey(
name: "fk_gotify_configs_notification_configs_notification_config_id",
column: x => x.notification_config_id,
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_gotify_configs_notification_config_id",
table: "gotify_configs",
column: "notification_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gotify_configs");
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAppStatusSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "status_check_enabled",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "status_check_enabled",
table: "general_configs");
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddExternalUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "external_url",
table: "download_clients",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "external_url",
table: "arr_instances",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "external_url",
table: "download_clients");
migrationBuilder.DropColumn(
name: "external_url",
table: "arr_instances");
}
}
}

View File

@@ -16,7 +16,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
@@ -60,6 +60,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("ExternalUrl")
.HasColumnType("TEXT")
.HasColumnName("external_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
@@ -219,6 +223,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("ExternalUrl")
.HasColumnType("TEXT")
.HasColumnName("external_url");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
@@ -302,7 +310,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
b.Property<bool>("StatusCheckEnabled")
.HasColumnType("INTEGER")
.HasColumnName("status_check_enabled");
b.ComplexProperty(typeof(Dictionary<string, object>), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
@@ -379,7 +391,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -397,7 +409,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -415,7 +427,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -433,7 +445,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -451,7 +463,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -522,6 +534,82 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("avatar_url");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT")
.HasColumnName("username");
b.Property<string>("WebhookUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("webhook_url");
b.HasKey("Id")
.HasName("pk_discord_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_discord_configs_notification_config_id");
b.ToTable("discord_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApplicationToken")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("application_token");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("ServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.HasKey("Id")
.HasName("pk_gotify_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_gotify_configs_notification_config_id");
b.ToTable("gotify_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
@@ -813,7 +901,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
@@ -1043,6 +1131,30 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("DiscordConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_discord_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("GotifyConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
@@ -1141,6 +1253,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
{
b.Navigation("AppriseConfiguration");
b.Navigation("DiscordConfiguration");
b.Navigation("GotifyConfiguration");
b.Navigation("NotifiarrConfiguration");
b.Navigation("NtfyConfiguration");

View File

@@ -21,7 +21,9 @@ public sealed class ArrInstance
public required string Name { get; set; }
public required Uri Url { get; set; }
public Uri? ExternalUrl { get; set; }
[SensitiveData]
public required string ApiKey { get; set; }
}

View File

@@ -59,14 +59,26 @@ public sealed record DownloadClientConfig
/// The base URL path component, used by clients like Transmission and Deluge
/// </summary>
public string? UrlBase { get; init; }
/// <summary>
/// Optional external URL for notifications when internal Docker URLs are not reachable externally
/// </summary>
public Uri? ExternalUrl { get; init; }
/// <summary>
/// The computed full URL for the client
/// </summary>
[NotMapped]
[JsonIgnore]
public Uri Url => new($"{Host?.ToString().TrimEnd('/')}/{UrlBase?.TrimStart('/').TrimEnd('/')}");
/// <summary>
/// Returns ExternalUrl if set, otherwise falls back to computed Url
/// </summary>
[NotMapped]
[JsonIgnore]
public Uri ExternalOrInternalUrl => ExternalUrl ?? Url;
/// <summary>
/// Validates the configuration
/// </summary>

View File

@@ -26,6 +26,8 @@ public sealed record GeneralConfig : IConfig
public ushort SearchDelay { get; set; } = Constants.DefaultSearchDelaySeconds;
public bool StatusCheckEnabled { get; set; } = true;
public string EncryptionKey { get; set; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; set; } = [];

View File

@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Persistence.Models.Configuration;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record DiscordConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
public Guid NotificationConfigId { get; init; }
[ForeignKey(nameof(NotificationConfigId))]
public NotificationConfig NotificationConfig { get; init; } = null!;
[Required]
[MaxLength(500)]
public string WebhookUrl { get; init; } = string.Empty;
[MaxLength(80)]
public string Username { get; init; } = string.Empty;
[MaxLength(500)]
public string AvatarUrl { get; init; } = string.Empty;
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(WebhookUrl);
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(WebhookUrl))
{
throw new ValidationException("Discord webhook URL is required");
}
if (!WebhookUrl.StartsWith("https://discord.com/api/webhooks/", StringComparison.OrdinalIgnoreCase) &&
!WebhookUrl.StartsWith("https://discordapp.com/api/webhooks/", StringComparison.OrdinalIgnoreCase))
{
throw new ValidationException("Discord webhook URL must be a valid Discord webhook URL");
}
if (!string.IsNullOrWhiteSpace(AvatarUrl) &&
!Uri.TryCreate(AvatarUrl, UriKind.Absolute, out var uri))
{
throw new ValidationException("Avatar URL must be a valid URL");
}
}
}

View File

@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record GotifyConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
public Guid NotificationConfigId { get; init; }
[ForeignKey(nameof(NotificationConfigId))]
public NotificationConfig NotificationConfig { get; init; } = null!;
[Required]
[MaxLength(500)]
public string ServerUrl { get; init; } = string.Empty;
[Required]
[MaxLength(200)]
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(ServerUrl) && !string.IsNullOrWhiteSpace(ApplicationToken);
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(ServerUrl))
{
throw new ValidationException("Gotify server URL is required");
}
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
throw new ValidationException("Gotify server URL must be a valid HTTP or HTTPS URL");
}
if (string.IsNullOrWhiteSpace(ApplicationToken))
{
throw new ValidationException("Gotify application token is required");
}
if (Priority < 0 || Priority > 10)
{
throw new ValidationException("Priority must be between 0 and 10");
}
}
}

View File

@@ -45,6 +45,10 @@ public sealed record NotificationConfig
public TelegramConfig? TelegramConfiguration { get; init; }
public DiscordConfig? DiscordConfiguration { get; init; }
public GotifyConfig? GotifyConfiguration { get; init; }
[NotMapped]
public bool IsConfigured => Type switch
{
@@ -53,6 +57,8 @@ public sealed record NotificationConfig
NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true,
NotificationProviderType.Pushover => PushoverConfiguration?.IsValid() == true,
NotificationProviderType.Telegram => TelegramConfiguration?.IsValid() == true,
NotificationProviderType.Discord => DiscordConfiguration?.IsValid() == true,
NotificationProviderType.Gotify => GotifyConfiguration?.IsValid() == true,
_ => throw new ArgumentOutOfRangeException(nameof(Type), $"Invalid notification provider type {Type}")
};

View File

@@ -17,4 +17,6 @@ public static class Constants
public const int DefaultSearchDelaySeconds = 120;
public const int MinSearchDelaySeconds = 60;
public const string LogoUrl = "https://cdn.jsdelivr.net/gh/Cleanuparr/Cleanuparr@main/Logo/48.png";
}

View File

@@ -26,6 +26,7 @@ yarn-error.log
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
@@ -36,6 +37,7 @@ yarn-error.log
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store

View File

@@ -1,7 +0,0 @@
{
"tabWidth": 2,
"useTabs": false,
"singleQuote": false,
"bracketSpacing": true,
"printWidth": 120
}

9
code/frontend/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

View File

@@ -12,10 +12,10 @@
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation complete"
"regexp": "bundle generation (complete|failed)"
}
}
}
@@ -30,10 +30,10 @@
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation complete"
"regexp": "bundle generation (complete|failed)"
}
}
}

View File

@@ -1,6 +1,6 @@
# Cleanuparr
# FrontendV2
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.12.
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.1.3.
## Development server
@@ -36,24 +36,6 @@ ng build
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -1,13 +1,39 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"Cleanuparr": {
"ui": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
"style": "scss",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
@@ -15,14 +41,9 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/ui",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
@@ -34,7 +55,11 @@
"styles": [
"src/styles.scss"
],
"scripts": []
"stylePreprocessorOptions": {
"includePaths": [
"src/styles"
]
}
},
"configurations": {
"production": {
@@ -42,16 +67,15 @@
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "10MB"
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "10kB",
"maximumError": "40kB"
"maximumWarning": "8kB",
"maximumError": "12kB"
}
],
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
"outputHashing": "all"
},
"development": {
"optimization": false,
@@ -62,57 +86,18 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "Cleanuparr:build:production"
"buildTarget": "ui:build:production"
},
"development": {
"buildTarget": "Cleanuparr:build:development"
"buildTarget": "ui:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"cli": {
"schematicCollections": [
"angular-eslint"
],
"analytics": false
}
}

View File

@@ -1,43 +0,0 @@
// @ts-check
const eslint = require("@eslint/js");
const tseslint = require("typescript-eslint");
const angular = require("angular-eslint");
module.exports = tseslint.config(
{
files: ["**/*.ts"],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase",
},
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case",
},
],
},
},
{
files: ["**/*.html"],
extends: [
...angular.configs.templateRecommended,
...angular.configs.templateAccessibility,
],
rules: {},
}
);

View File

@@ -1,30 +0,0 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,46 +5,54 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lint": "ng lint"
"watch": "ng build --watch --configuration development"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"packageManager": "npm@11.6.2",
"dependencies": {
"@angular/animations": "^19.2.17",
"@angular/cdk": "^19.2.17",
"@angular/common": "^19.2.17",
"@angular/compiler": "^19.2.17",
"@angular/core": "^19.2.17",
"@angular/forms": "^19.2.17",
"@angular/platform-browser": "^19.2.17",
"@angular/platform-browser-dynamic": "^19.2.17",
"@angular/router": "^19.2.17",
"@angular/service-worker": "^19.2.17",
"@microsoft/signalr": "^8.0.7",
"@ngrx/signals": "^19.2.0",
"@primeng/themes": "^19.1.3",
"primeflex": "^4.0.0",
"primeicons": "^7.0.0",
"primeng": "^19.1.3",
"@angular/animations": "^21.1.3",
"@angular/cdk": "^21.1.3",
"@angular/common": "^21.1.0",
"@angular/compiler": "^21.1.0",
"@angular/core": "^21.1.0",
"@angular/forms": "^21.1.0",
"@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0",
"@microsoft/signalr": "^10.0.0",
"@ng-icons/core": "^33.0.0",
"@ng-icons/tabler-icons": "^33.0.0",
"@ngrx/signals": "^21.0.1",
"@tailwindcss/postcss": "^4.1.18",
"postcss": "^8.5.6",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
"tailwindcss": "^4.1.18",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.17",
"@angular/cli": "^19.2.17",
"@angular/compiler-cli": "^19.2.17",
"@types/jasmine": "~5.1.0",
"angular-eslint": "19.6.0",
"eslint": "^9.27.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2",
"typescript-eslint": "8.32.1"
"@angular-eslint/builder": "^21.2.0",
"@angular-eslint/eslint-plugin": "^21.2.0",
"@angular-eslint/eslint-plugin-template": "^21.2.0",
"@angular-eslint/template-parser": "^21.2.0",
"@angular/build": "^21.1.3",
"@angular/cli": "^21.1.3",
"@angular/compiler-cli": "^21.1.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"typescript": "~5.9.2"
}
}
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M433.7 91a416.5 416.5 0 0 0-105.6-33.2c-4.6 8.2-9.9 19.3-13.5 28.1-39.4-5.9-78.4-5.9-117.1 0-3.7-8.8-9.1-19.9-13.7-28.1-37.1 6.4-72.6 17.7-105.7 33.3-66.8 101-85 199.5-75.9 296.6 44.3 33.1 87.3 53.2 129.6 66.4 10.4-14.4 19.7-29.6 27.7-45.7-15.3-5.8-29.9-13-43.7-21.3 3.7-2.7 7.2-5.6 10.7-8.5 84.2 39.4 175.8 39.4 259 0 3.5 2.9 7.1 5.8 10.7 8.5-13.9 8.3-28.5 15.5-43.8 21.3 8 16 17.3 31.3 27.7 45.7 42.3-13.2 85.3-33.3 129.6-66.4 10.8-112.5-18-210.1-76-296.7M170.9 328c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c.1 28.8-20.2 52.4-46 52.4m170.2 0c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c0 28.8-20.3 52.4-46 52.4" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 766 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M433.7 91a416.5 416.5 0 0 0-105.6-33.2c-4.6 8.2-9.9 19.3-13.5 28.1-39.4-5.9-78.4-5.9-117.1 0-3.7-8.8-9.1-19.9-13.7-28.1-37.1 6.4-72.6 17.7-105.7 33.3-66.8 101-85 199.5-75.9 296.6 44.3 33.1 87.3 53.2 129.6 66.4 10.4-14.4 19.7-29.6 27.7-45.7-15.3-5.8-29.9-13-43.7-21.3 3.7-2.7 7.2-5.6 10.7-8.5 84.2 39.4 175.8 39.4 259 0 3.5 2.9 7.1 5.8 10.7 8.5-13.9 8.3-28.5 15.5-43.8 21.3 8 16 17.3 31.3 27.7 45.7 42.3-13.2 85.3-33.3 129.6-66.4 10.8-112.5-18-210.1-76-296.7M170.9 328c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c.1 28.8-20.2 52.4-46 52.4m170.2 0c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c0 28.8-20.3 52.4-46 52.4" style="fill:#5865f2"/></svg>

After

Width:  |  Height:  |  Size: 769 B

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -0,0 +1,16 @@
{
"name": "Cleanuparr",
"short_name": "Cleanuparr",
"description": "Automated cleanup for *arr applications and download clients",
"start_url": "/",
"display": "standalone",
"background_color": "#0e0a1a",
"theme_color": "#1a1135",
"icons": [
{
"src": "icons/128.png",
"sizes": "128x128",
"type": "image/png"
}
]
}

View File

@@ -1,3 +0,0 @@
<app-main-layout>
<router-outlet></router-outlet>
</app-main-layout>

View File

View File

@@ -1,29 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'Cleanuparr' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('Cleanuparr');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Cleanuparr');
});
});

View File

@@ -1,14 +0,0 @@
import { Component, inject } from '@angular/core';
import { MainLayoutComponent } from './layout/main-layout/main-layout.component';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, MainLayoutComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
title = 'Cleanuparr';
}

View File

@@ -1,29 +1,101 @@
import { ApplicationConfig, provideZoneChangeDetection, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { providePrimeNG } from 'primeng/config';
import { ConfirmationService } from 'primeng/api';
import { provideIcons } from '@ng-icons/core';
import {
tablerLayoutDashboard,
tablerFileText,
tablerBell,
tablerSettings,
tablerPlaylistX,
tablerShieldLock,
tablerTrash,
tablerBan,
tablerDownload,
tablerBellRinging,
tablerChevronDown,
tablerChevronRight,
tablerMenu2,
tablerLayoutSidebarLeftCollapse,
tablerSun,
tablerMoon,
tablerCheck,
tablerBrandGithub,
tablerBrandDiscord,
tablerHeart,
tablerStar,
tablerExternalLink,
tablerQuestionMark,
tablerPencil,
tablerPlus,
tablerAlertTriangle,
tablerX,
tablerEye,
tablerEyeOff,
tablerArrowDown,
tablerCircleX,
tablerInfoCircle,
tablerCode,
tablerCircle,
tablerBolt,
tablerTag,
tablerChevronUp,
tablerCopy,
tablerFileExport,
} from '@ng-icons/tabler-icons';
import { routes } from './app.routes';
import { provideServiceWorker } from '@angular/service-worker';
import Noir from './app.preset';
import { authInterceptor } from '@core/auth/auth.interceptor';
import { baseUrlInterceptor } from '@core/interceptors/base-url.interceptor';
import { errorInterceptor } from '@core/interceptors/error.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptorsFromDi()),
{ provide: ConfirmationService },
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([baseUrlInterceptor, authInterceptor, errorInterceptor])),
provideAnimationsAsync(),
providePrimeNG({
theme: {
preset: Noir
}
provideIcons({
tablerLayoutDashboard,
tablerFileText,
tablerBell,
tablerSettings,
tablerPlaylistX,
tablerShieldLock,
tablerTrash,
tablerBan,
tablerDownload,
tablerBellRinging,
tablerChevronDown,
tablerChevronRight,
tablerMenu2,
tablerLayoutSidebarLeftCollapse,
tablerSun,
tablerMoon,
tablerCheck,
tablerBrandGithub,
tablerBrandDiscord,
tablerHeart,
tablerStar,
tablerExternalLink,
tablerQuestionMark,
tablerPencil,
tablerPlus,
tablerAlertTriangle,
tablerX,
tablerEye,
tablerEyeOff,
tablerArrowDown,
tablerCircleX,
tablerInfoCircle,
tablerCode,
tablerCircle,
tablerBolt,
tablerTag,
tablerChevronUp,
tablerCopy,
tablerFileExport,
}),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
})
]
],
};

View File

@@ -1,50 +0,0 @@
import { definePreset } from '@primeng/themes';
import Aura from '@primeng/themes/aura';
const Noir = definePreset(Aura, {
semantic: {
// Use purple as the primary color palette
primary: {
50: '{violet.50}',
100: '{violet.100}',
200: '{violet.200}',
300: '{violet.300}',
400: '{violet.400}',
500: '{violet.500}',
600: '{violet.600}',
700: '{violet.700}',
800: '{violet.800}',
900: '{violet.900}',
950: '{violet.950}'
},
colorScheme: {
// Skip light mode configuration since we're only using dark mode
dark: {
// Base colors for dark mode
surface: {
ground: '#121212', // Very dark gray for main background
section: '#1a1a1a', // Slightly lighter for sections
card: '#212121', // Card background
overlay: '#262626', // Overlay surface
border: '#383838', // Border color
hover: 'rgba(255,255,255,.03)' // Subtle hover effect
},
// Purple accent configuration
primary: {
color: '{violet.500}', // Main purple (medium brightness)
inverseColor: '#ffffff', // White text on purple backgrounds
hoverColor: '{violet.400}', // Lighter on hover
activeColor: '{violet.300}' // Even lighter when active
},
highlight: {
background: 'rgba(124, 58, 237, 0.16)', // Subtle purple highlight
focusBackground: 'rgba(124, 58, 237, 0.24)', // Slightly stronger when focused
color: 'rgba(255,255,255,.87)',
focusColor: 'rgba(255,255,255,.87)'
}
}
}
}
});
export default Noir;

View File

@@ -1,43 +1,118 @@
import { Routes } from '@angular/router';
import { pendingChangesGuard } from './core/guards/pending-changes.guard';
import { ShellComponent } from '@layout/shell/shell.component';
import { AuthLayoutComponent } from '@layout/auth-layout/auth-layout.component';
import { authGuard } from '@core/auth/auth.guard';
import { pendingChangesGuard } from '@core/guards/pending-changes.guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard-page/dashboard-page.component').then(m => m.DashboardPageComponent) },
{ path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) },
{ path: 'events', loadComponent: () => import('./events/events-viewer/events-viewer.component').then(m => m.EventsViewerComponent) },
{
path: 'general-settings',
loadComponent: () => import('./settings/general-settings/general-settings.component').then(m => m.GeneralSettingsComponent),
canDeactivate: [pendingChangesGuard]
{
path: '',
component: ShellComponent,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () =>
import('@features/dashboard/dashboard.component').then(
(m) => m.DashboardComponent,
),
},
{
path: 'logs',
loadComponent: () =>
import('@app/features/logs-component/logs.component').then((m) => m.LogsComponent),
},
{
path: 'events',
loadComponent: () =>
import('@features/events/events.component').then(
(m) => m.EventsComponent,
),
},
{
path: 'settings',
children: [
{
path: 'general',
loadComponent: () =>
import(
'@features/settings/general/general-settings.component'
).then((m) => m.GeneralSettingsComponent),
canDeactivate: [pendingChangesGuard],
},
{
path: 'queue-cleaner',
loadComponent: () =>
import(
'@features/settings/queue-cleaner/queue-cleaner.component'
).then((m) => m.QueueCleanerComponent),
canDeactivate: [pendingChangesGuard],
},
{
path: 'malware-blocker',
loadComponent: () =>
import(
'@features/settings/malware-blocker/malware-blocker.component'
).then((m) => m.MalwareBlockerComponent),
canDeactivate: [pendingChangesGuard],
},
{
path: 'download-cleaner',
loadComponent: () =>
import(
'@features/settings/download-cleaner/download-cleaner.component'
).then((m) => m.DownloadCleanerComponent),
canDeactivate: [pendingChangesGuard],
},
{
path: 'blacklist-sync',
loadComponent: () =>
import(
'@features/settings/blacklist-sync/blacklist-sync.component'
).then((m) => m.BlacklistSyncComponent),
canDeactivate: [pendingChangesGuard],
},
{
path: 'arr/:type',
loadComponent: () =>
import(
'@features/settings/arr/arr-settings.component'
).then((m) => m.ArrSettingsComponent),
canDeactivate: [pendingChangesGuard],
},
{
path: 'download-clients',
loadComponent: () =>
import(
'@features/settings/download-clients/download-clients.component'
).then((m) => m.DownloadClientsComponent),
canDeactivate: [pendingChangesGuard],
},
{
path: 'notifications',
loadComponent: () =>
import(
'@features/settings/notifications/notifications.component'
).then((m) => m.NotificationsComponent),
canDeactivate: [pendingChangesGuard],
},
],
},
],
},
{
path: 'queue-cleaner',
loadComponent: () => import('./settings/queue-cleaner/queue-cleaner-settings.component').then(m => m.QueueCleanerSettingsComponent),
canDeactivate: [pendingChangesGuard]
{
path: 'auth',
component: AuthLayoutComponent,
children: [
{
path: 'login',
loadComponent: () =>
import('@features/auth/login/login.component').then(
(m) => m.LoginComponent,
),
},
],
},
{
path: 'malware-blocker',
loadComponent: () => import('./settings/malware-blocker/malware-blocker-settings.component').then(m => m.MalwareBlockerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'download-cleaner',
loadComponent: () => import('./settings/download-cleaner/download-cleaner-settings.component').then(m => m.DownloadCleanerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'blacklist-synchronizer',
loadComponent: () => import('./settings/blacklist-sync/blacklist-sync-settings.component').then(m => m.BlacklistSyncSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },
{ path: 'readarr', loadComponent: () => import('./settings/readarr/readarr-settings.component').then(m => m.ReadarrSettingsComponent) },
{ path: 'whisparr', loadComponent: () => import('./settings/whisparr/whisparr-settings.component').then(m => m.WhisparrSettingsComponent) },
{ path: 'download-clients', loadComponent: () => import('./settings/download-client/download-client-settings.component').then(m => m.DownloadClientSettingsComponent) },
{ path: 'notifications', loadComponent: () => import('./settings/notification-settings/notification-settings.component').then(m => m.NotificationSettingsComponent) },
{ path: '**', redirectTo: 'dashboard' },
];

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