mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-30 17:39:03 -05:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc45233223 | ||
|
|
5d12d601ae | ||
|
|
88f40438af | ||
|
|
0a9ec06841 | ||
|
|
a0ca6ec4b8 | ||
|
|
eb6cf96470 | ||
|
|
2ca0616771 | ||
|
|
bc85144e60 | ||
|
|
236e31c841 | ||
|
|
7a15139aa6 | ||
|
|
fb6ccfd011 | ||
|
|
ef85e2b690 | ||
|
|
bb734230aa | ||
|
|
aa31c31955 | ||
|
|
1a89822f36 | ||
|
|
fc9e0eca36 | ||
|
|
0010dcb1c6 | ||
|
|
0ab8611f29 | ||
|
|
9e02408a7e | ||
|
|
1bd0db05e6 |
20
.github/workflows/build-executable.yml
vendored
20
.github/workflows/build-executable.yml
vendored
@@ -134,22 +134,4 @@ jobs:
|
||||
./artifacts/*.zip
|
||||
retention-days: 30
|
||||
|
||||
- name: Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
id: release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ env.releaseVersion }}
|
||||
tag_name: ${{ env.releaseVersion }}
|
||||
repository: ${{ env.githubRepository }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
make_latest: true
|
||||
fail_on_unmatched_files: true
|
||||
target_commitish: main
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip
|
||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip
|
||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip
|
||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip
|
||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip
|
||||
# Removed individual release step - handled by main release workflow
|
||||
12
.github/workflows/build-macos-arm-installer.yml
vendored
12
.github/workflows/build-macos-arm-installer.yml
vendored
@@ -363,14 +363,4 @@ jobs:
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
|
||||
- name: Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ env.releaseVersion }}
|
||||
tag_name: ${{ env.releaseVersion }}
|
||||
repository: ${{ env.githubRepository }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
make_latest: true
|
||||
files: |
|
||||
${{ env.pkgName }}
|
||||
# Removed individual release step - handled by main release workflow
|
||||
@@ -363,14 +363,4 @@ jobs:
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
|
||||
- name: Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ env.releaseVersion }}
|
||||
tag_name: ${{ env.releaseVersion }}
|
||||
repository: ${{ env.githubRepository }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
make_latest: true
|
||||
files: |
|
||||
${{ env.pkgName }}
|
||||
# Removed individual release step - handled by main release workflow
|
||||
25
.github/workflows/build-windows-installer.yml
vendored
25
.github/workflows/build-windows-installer.yml
vendored
@@ -88,19 +88,6 @@ jobs:
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
|
||||
|
||||
- name: Create sample configuration
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Create config directory
|
||||
New-Item -ItemType Directory -Force -Path "config"
|
||||
|
||||
$config = @{
|
||||
"HTTP_PORTS" = 11011
|
||||
"BASE_PATH" = "/"
|
||||
}
|
||||
|
||||
$config | ConvertTo-Json | Out-File -FilePath "config/cleanuparr.json" -Encoding UTF8
|
||||
|
||||
- name: Setup Inno Setup
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -158,14 +145,4 @@ jobs:
|
||||
path: installer/${{ env.installerName }}
|
||||
retention-days: 30
|
||||
|
||||
- name: Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ env.releaseVersion }}
|
||||
tag_name: ${{ env.releaseVersion }}
|
||||
repository: ${{ env.githubRepository }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
make_latest: true
|
||||
files: |
|
||||
installer/${{ env.installerName }}
|
||||
# Removed individual release step - handled by main release workflow
|
||||
75
README.md
75
README.md
@@ -12,34 +12,67 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> **Features:**
|
||||
> - Strike system to mark bad downloads.
|
||||
> - Remove and block downloads that reached a maximum number of strikes.
|
||||
> - Remove and block downloads that are **failing to be imported** by the arrs. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#failed-import-max-strikes)
|
||||
> - Remove and block downloads that are **stalled** or in **metadata downloading** state. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#stalled-max-strikes)
|
||||
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#slow-max-strikes)
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/content-blocker)
|
||||
> - Remove and block downloads that are **failing to be imported** by the arrs.
|
||||
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
|
||||
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/download-cleaner#seeding-settings)
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/download-cleaner#enable-unlinked-downloads-management)
|
||||
> - Notify on strike or download removal. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/notifications)
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||
|
||||
Cleanuparr supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
|
||||
## 🎯 Supported Applications
|
||||
|
||||
## Quick Start
|
||||
### *Arr Applications
|
||||
- **Sonarr** (TV Shows)
|
||||
- **Radarr** (Movies)
|
||||
- **Lidarr** (Music)
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 1. **Docker (Recommended)**
|
||||
> Pull the Docker image from `ghcr.io/cleanuparr/cleanuparr:latest`.
|
||||
>
|
||||
> 2. **Unraid (for Unraid users)**
|
||||
> Use the Unraid Community App.
|
||||
>
|
||||
> 3. **Manual Installation (if you're not using Docker)**
|
||||
> Go to [Windows](#windows), [Linux](#linux) or [MacOS](#macos).
|
||||
### Download Clients
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
|
||||
# Docs
|
||||
### Platforms
|
||||
- **Docker** (Linux, Windows, macOS)
|
||||
- **Windows** (Native installer)
|
||||
- **macOS** (Intel & Apple Silicon)
|
||||
- **Linux** (Portable executable)
|
||||
- **Unraid** (Community Apps)
|
||||
|
||||
Docs can be found [here](https://cleanuparr.github.io/Cleanuparr/).
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d --name cleanuparr \
|
||||
--restart unless-stopped \
|
||||
-p 11011:11011 \
|
||||
-v /path/to/config:/config \
|
||||
-e PORT=11011 \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
ghcr.io/cleanuparr/cleanuparr:latest
|
||||
```
|
||||
|
||||
For Docker Compose, health checks, and other installation methods, see our [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
|
||||
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
After installation, open your browser and navigate to:
|
||||
```
|
||||
http://localhost:11011
|
||||
```
|
||||
|
||||
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
|
||||
|
||||
## 📖 Documentation & Support
|
||||
|
||||
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
|
||||
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
|
||||
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
|
||||
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
|
||||
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
|
||||
|
||||
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
|
||||
|
||||
# Install required packages for user management and timezone support
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
tzdata \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -3,10 +3,10 @@ using Cleanuparr.Application.Features.Arr.Dtos;
|
||||
using Cleanuparr.Application.Features.DownloadClient.Dtos;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
@@ -16,7 +16,6 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Infrastructure.Services.Interfaces;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -309,6 +308,24 @@ public class ConfigurationController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("readarr")]
|
||||
public async Task<IActionResult> GetReadarrConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("notifications")]
|
||||
public async Task<IActionResult> GetNotificationsConfig()
|
||||
{
|
||||
@@ -773,6 +790,37 @@ public class ConfigurationController : ControllerBase
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("readarr")]
|
||||
public async Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateReadarrConfigDto newConfigDto)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Get existing config
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
|
||||
config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes;
|
||||
|
||||
// Validate the configuration
|
||||
config.Validate();
|
||||
|
||||
// Persist the configuration
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { Message = "Readarr configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save Readarr configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a job schedule based on configuration changes
|
||||
@@ -1137,4 +1185,114 @@ public class ConfigurationController : ControllerBase
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("readarr/instances")]
|
||||
public async Task<IActionResult> CreateReadarrInstance([FromBody] CreateArrInstanceDto newInstance)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Get the Readarr config to add the instance to
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
|
||||
// Create the new instance
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Enabled = newInstance.Enabled,
|
||||
Name = newInstance.Name,
|
||||
Url = new Uri(newInstance.Url),
|
||||
ApiKey = newInstance.ApiKey,
|
||||
ArrConfigId = config.Id,
|
||||
};
|
||||
|
||||
// Add to the config's instances collection
|
||||
await _dataContext.ArrInstances.AddAsync(instance);
|
||||
// Save changes
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetReadarrConfig), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Readarr instance");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("readarr/instances/{id}")]
|
||||
public async Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Get the Readarr config and find the instance
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance == null)
|
||||
{
|
||||
return NotFound($"Readarr instance with ID {id} not found");
|
||||
}
|
||||
|
||||
// Update the instance properties
|
||||
instance.Enabled = updatedInstance.Enabled;
|
||||
instance.Name = updatedInstance.Name;
|
||||
instance.Url = new Uri(updatedInstance.Url);
|
||||
instance.ApiKey = updatedInstance.ApiKey;
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Readarr instance with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("readarr/instances/{id}")]
|
||||
public async Task<IActionResult> DeleteReadarrInstance(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Get the Readarr config and find the instance
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance == null)
|
||||
{
|
||||
return NotFound($"Readarr instance with ID {id} not found");
|
||||
}
|
||||
|
||||
// Remove the instance
|
||||
config.Instances.Remove(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete Readarr instance with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
125
code/backend/Cleanuparr.Api/Controllers/HealthController.cs
Normal file
125
code/backend/Cleanuparr.Api/Controllers/HealthController.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Health check endpoints for Docker and Kubernetes
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly HealthCheckService _healthCheckService;
|
||||
private readonly ILogger<HealthController> _logger;
|
||||
|
||||
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger)
|
||||
{
|
||||
_healthCheckService = healthCheckService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Basic liveness probe - checks if the application is running
|
||||
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Route("/health")]
|
||||
public async Task<IActionResult> GetHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckHealthAsync(
|
||||
registration => registration.Tags.Contains("liveness"));
|
||||
|
||||
return result.Status == HealthStatus.Healthy
|
||||
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
|
||||
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Health check failed");
|
||||
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Readiness probe - checks if the application is ready to serve traffic
|
||||
/// Used by Kubernetes readiness probes
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Route("/health/ready")]
|
||||
public async Task<IActionResult> GetReadiness()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckHealthAsync(
|
||||
registration => registration.Tags.Contains("readiness"));
|
||||
|
||||
if (result.Status == HealthStatus.Healthy)
|
||||
{
|
||||
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
// For readiness, we consider degraded as not ready
|
||||
return StatusCode(503, new {
|
||||
status = "not_ready",
|
||||
timestamp = DateTime.UtcNow,
|
||||
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
|
||||
.ToDictionary(e => e.Key, e => new {
|
||||
status = e.Value.Status.ToString().ToLowerInvariant(),
|
||||
description = e.Value.Description
|
||||
})
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Readiness check failed");
|
||||
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed health status - for monitoring and debugging
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Route("/health/detailed")]
|
||||
public async Task<IActionResult> GetDetailedHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckHealthAsync();
|
||||
|
||||
var response = new
|
||||
{
|
||||
status = result.Status.ToString().ToLowerInvariant(),
|
||||
timestamp = DateTime.UtcNow,
|
||||
totalDuration = result.TotalDuration.TotalMilliseconds,
|
||||
entries = result.Entries.ToDictionary(
|
||||
e => e.Key,
|
||||
e => new
|
||||
{
|
||||
status = e.Value.Status.ToString().ToLowerInvariant(),
|
||||
description = e.Value.Description,
|
||||
duration = e.Value.Duration.TotalMilliseconds,
|
||||
tags = e.Value.Tags,
|
||||
data = e.Value.Data,
|
||||
exception = e.Value.Exception?.Message
|
||||
})
|
||||
};
|
||||
|
||||
return result.Status == HealthStatus.Healthy
|
||||
? Ok(response)
|
||||
: StatusCode(503, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Detailed health check failed");
|
||||
return StatusCode(503, new {
|
||||
status = "unhealthy",
|
||||
error = "Detailed health check failed",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Cleanuparr.Api.Models;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
@@ -52,6 +52,10 @@ public class StatusController : ControllerBase
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Lidarr);
|
||||
var readarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
|
||||
var status = new
|
||||
{
|
||||
@@ -80,6 +84,10 @@ public class StatusController : ControllerBase
|
||||
Lidarr = new
|
||||
{
|
||||
InstanceCount = lidarrConfig.Instances.Count
|
||||
},
|
||||
Readarr = new
|
||||
{
|
||||
InstanceCount = readarrConfig.Instances.Count
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,9 +40,6 @@ public static class ApiDI
|
||||
// Add health status broadcaster
|
||||
services.AddHostedService<HealthStatusBroadcaster>();
|
||||
|
||||
// Add logging initializer service
|
||||
services.AddHostedService<LoggingInitializer>();
|
||||
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -83,9 +83,17 @@ public static class MainDI
|
||||
/// </summary>
|
||||
private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
|
||||
services
|
||||
// Register the health check service
|
||||
// Register the existing health check service for download clients
|
||||
.AddSingleton<IHealthCheckService, HealthCheckService>()
|
||||
|
||||
// Register the background service for periodic health checks
|
||||
.AddHostedService<HealthCheckBackgroundService>();
|
||||
.AddHostedService<HealthCheckBackgroundService>()
|
||||
|
||||
// Add ASP.NET Core health checks
|
||||
.AddHealthChecks()
|
||||
.AddCheck<ApplicationHealthCheck>("application", tags: ["liveness"])
|
||||
.AddCheck<DatabaseHealthCheck>("database", tags: ["readiness"])
|
||||
.AddCheck<FileSystemHealthCheck>("filesystem", tags: ["readiness"])
|
||||
.AddCheck<DownloadClientsHealthCheck>("download_clients", tags: ["readiness"])
|
||||
.Services;
|
||||
}
|
||||
@@ -12,9 +12,9 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Services.Interfaces;
|
||||
using Infrastructure.Verticals.Files;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
@@ -37,6 +37,7 @@ public static class ServicesDI
|
||||
.AddTransient<SonarrClient>()
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<LidarrClient>()
|
||||
.AddTransient<ReadarrClient>()
|
||||
.AddTransient<ArrClientFactory>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
|
||||
28
code/backend/Cleanuparr.Api/HealthCheckResponseWriter.cs
Normal file
28
code/backend/Cleanuparr.Api/HealthCheckResponseWriter.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using System.Text;
|
||||
|
||||
namespace Cleanuparr.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Custom health check response writers for different formats
|
||||
/// </summary>
|
||||
public static class HealthCheckResponseWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes a minimal plain text response suitable for Docker health checks
|
||||
/// </summary>
|
||||
public static async Task WriteMinimalPlaintext(HttpContext context, HealthReport report)
|
||||
{
|
||||
context.Response.ContentType = "text/plain";
|
||||
|
||||
var status = report.Status switch
|
||||
{
|
||||
HealthStatus.Healthy => "healthy",
|
||||
HealthStatus.Degraded => "degraded",
|
||||
HealthStatus.Unhealthy => "unhealthy",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
await context.Response.WriteAsync(status, Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ public class BackgroundJobManager : IHostedService
|
||||
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
|
||||
.WithIdentity("ValidationTrigger")
|
||||
.StartNow()
|
||||
.WithCronSchedule(cronExpression)
|
||||
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
|
||||
.Build();
|
||||
|
||||
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
|
||||
@@ -197,26 +197,26 @@ public class BackgroundJobManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
// Create cron trigger
|
||||
// Create main cron trigger with consistent naming (matches JobManagementService)
|
||||
var trigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{typeName}-trigger")
|
||||
.ForJob(jobKey)
|
||||
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
// Create startup trigger to run immediately
|
||||
// Schedule the main trigger
|
||||
await _scheduler.ScheduleJob(trigger, cancellationToken);
|
||||
|
||||
// Trigger immediate execution for startup using a one-time trigger
|
||||
var startupTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{typeName}-startup-trigger")
|
||||
.WithIdentity($"{typeName}-startup-{DateTimeOffset.UtcNow.Ticks}")
|
||||
.ForJob(jobKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
// Schedule job with both triggers
|
||||
await _scheduler.ScheduleJob(trigger, cancellationToken);
|
||||
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Added triggers for job {name} with cron expression {CronExpression}",
|
||||
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression} and immediate startup execution",
|
||||
typeName, cronExpression);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ using Cleanuparr.Api;
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -147,6 +149,19 @@ logConfig.WriteTo.Sink(signalRSink);
|
||||
|
||||
Log.Logger = logConfig.CreateLogger();
|
||||
|
||||
// Configure health check endpoints before the API configuration
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("liveness"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("readiness"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
|
||||
});
|
||||
|
||||
app.ConfigureApi();
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating Readarr configuration basic settings (instances managed separately)
|
||||
/// </summary>
|
||||
public record UpdateReadarrConfigDto
|
||||
{
|
||||
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
@@ -63,6 +64,7 @@ public sealed class ContentBlocker : GenericHandler
|
||||
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
|
||||
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
|
||||
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
|
||||
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
|
||||
|
||||
if (config.Sonarr.Enabled)
|
||||
{
|
||||
@@ -78,6 +80,11 @@ public sealed class ContentBlocker : GenericHandler
|
||||
{
|
||||
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
|
||||
}
|
||||
|
||||
if (config.Readarr.Enabled)
|
||||
{
|
||||
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
@@ -130,6 +131,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), InstanceType.Radarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
|
||||
|
||||
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
@@ -42,10 +43,12 @@ public sealed class QueueCleaner : GenericHandler
|
||||
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
|
||||
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
|
||||
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
|
||||
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
|
||||
|
||||
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
|
||||
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
|
||||
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
|
||||
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public record Image
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public record LidarrImage
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueAlbum
|
||||
{
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueBook
|
||||
{
|
||||
public List<ReadarrImage> Images { get; init; } = [];
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public record QueueListResponse
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueMovie
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueRecord
|
||||
{
|
||||
@@ -21,6 +23,13 @@ public sealed record QueueRecord
|
||||
|
||||
public QueueAlbum? Album { get; init; }
|
||||
|
||||
// Readarr
|
||||
public long AuthorId { get; init; }
|
||||
|
||||
public long BookId { get; init; }
|
||||
|
||||
public QueueBook? Book { get; init; }
|
||||
|
||||
// common
|
||||
public required string Title { get; init; }
|
||||
public string Status { get; init; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueSeries
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record ReadarrImage
|
||||
{
|
||||
public required string CoverType { get; init; }
|
||||
|
||||
public required Uri Url { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Readarr;
|
||||
|
||||
public sealed record Author
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public string AuthorName { get; set; } = string.Empty;
|
||||
}
|
||||
12
code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs
Normal file
12
code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Domain.Entities.Readarr;
|
||||
|
||||
public sealed record Book
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
|
||||
public required string Title { get; init; }
|
||||
|
||||
public long AuthorId { get; set; }
|
||||
|
||||
public Author Author { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Readarr;
|
||||
|
||||
public sealed record ReadarrCommand
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public List<long> BookIds { get; set; } = [];
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Infrastructure.Services;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Extensions;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Infrastructure.Services;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Extensions;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Infrastructure.Services;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Extensions;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
|
||||
@@ -8,16 +8,19 @@ public sealed class ArrClientFactory
|
||||
private readonly ISonarrClient _sonarrClient;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ILidarrClient _lidarrClient;
|
||||
private readonly IReadarrClient _readarrClient;
|
||||
|
||||
public ArrClientFactory(
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
LidarrClient lidarrClient
|
||||
LidarrClient lidarrClient,
|
||||
ReadarrClient readarrClient
|
||||
)
|
||||
{
|
||||
_sonarrClient = sonarrClient;
|
||||
_radarrClient = radarrClient;
|
||||
_lidarrClient = lidarrClient;
|
||||
_readarrClient = readarrClient;
|
||||
}
|
||||
|
||||
public IArrClient GetClient(InstanceType type) =>
|
||||
@@ -26,6 +29,7 @@ public sealed class ArrClientFactory
|
||||
InstanceType.Sonarr => _sonarrClient,
|
||||
InstanceType.Radarr => _radarrClient,
|
||||
InstanceType.Lidarr => _lidarrClient,
|
||||
InstanceType.Readarr => _readarrClient,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IReadarrClient : IArrClient
|
||||
{
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Lidarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Radarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Readarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr;
|
||||
|
||||
public class ReadarrClient : ArrClient, IReadarrClient
|
||||
{
|
||||
public ReadarrClient(
|
||||
ILogger<ReadarrClient> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IStriker striker,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlPath()
|
||||
{
|
||||
return "/api/v1/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
|
||||
}
|
||||
|
||||
protected override string GetQueueDeleteUrlPath(long recordId)
|
||||
{
|
||||
return $"/api/v1/queue/{recordId}";
|
||||
}
|
||||
|
||||
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
|
||||
{
|
||||
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
|
||||
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<long> ids = items.Select(item => item.Id).ToList();
|
||||
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/command";
|
||||
|
||||
ReadarrCommand command = new()
|
||||
{
|
||||
Name = "BookSearch",
|
||||
BookIds = ids,
|
||||
};
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Content = new StringContent(
|
||||
JsonConvert.SerializeObject(command),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
|
||||
response?.Dispose();
|
||||
|
||||
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.AuthorId is 0 || record.BookId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | author id and/or book id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
|
||||
private static string GetSearchLog(Uri instanceUrl, ReadarrCommand command, bool success, string? logContext)
|
||||
{
|
||||
string status = success ? "triggered" : "failed";
|
||||
string message = logContext ?? $"book ids: {string.Join(',', command.BookIds)}";
|
||||
|
||||
return $"book search {status} | {instanceUrl} | {message}";
|
||||
}
|
||||
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, ReadarrCommand command)
|
||||
{
|
||||
try
|
||||
{
|
||||
StringBuilder log = new();
|
||||
|
||||
foreach (long bookId in command.BookIds)
|
||||
{
|
||||
Book? book = await GetBookAsync(arrInstance, bookId);
|
||||
|
||||
if (book is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
log.Append($"[{book.Title}]");
|
||||
}
|
||||
|
||||
return log.ToString();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to compute log context");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<Book?> GetBookAsync(ArrInstance arrInstance, long bookId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/book/{bookId}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<Book>(responseBody);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Sonarr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
@@ -95,6 +95,17 @@ public sealed class BlocklistProvider
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Lidarr blocklist if needed
|
||||
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Readarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
|
||||
_configHashes[InstanceType.Readarr] = readarrHash;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
if (changedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);
|
||||
|
||||
@@ -21,7 +21,7 @@ public partial class DelugeService
|
||||
|
||||
if (download?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public partial class DelugeService
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
|
||||
}
|
||||
|
||||
if (contents is null)
|
||||
|
||||
@@ -25,7 +25,7 @@ public partial class DelugeService
|
||||
|
||||
if (download?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public partial class DelugeService
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public partial class QBitService
|
||||
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public partial class QBitService
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
|
||||
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ public partial class QBitService
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name);
|
||||
_logger.LogDebug("failed to find torrent properties | {name}", download.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public partial class QBitService
|
||||
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public partial class QBitService
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
|
||||
_logger.LogDebug("failed to find torrent properties {hash}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public partial class TransmissionService
|
||||
|
||||
if (download?.FileStats is null || download.FileStats.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ public partial class TransmissionService
|
||||
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
@@ -52,68 +53,6 @@ public abstract class GenericHandler : IHandler
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
// /// <summary>
|
||||
// /// Initialize download services based on configuration
|
||||
// /// </summary>
|
||||
// protected async Task<List<IDownloadService>> GetDownloadServices()
|
||||
// {
|
||||
// var clients = await _dataContext.DownloadClients
|
||||
// .AsNoTracking()
|
||||
// .ToListAsync();
|
||||
//
|
||||
// if (clients.Count is 0)
|
||||
// {
|
||||
// _logger.LogWarning("No download clients configured");
|
||||
// return [];
|
||||
// }
|
||||
//
|
||||
// var enabledClients = await _dataContext.DownloadClients
|
||||
// .Where(c => c.Enabled)
|
||||
// .ToListAsync();
|
||||
//
|
||||
// if (enabledClients.Count == 0)
|
||||
// {
|
||||
// _logger.LogWarning("No enabled download clients available");
|
||||
// return [];
|
||||
// }
|
||||
//
|
||||
// List<IDownloadService> downloadServices = [];
|
||||
//
|
||||
// // Add all enabled clients
|
||||
// foreach (var client in enabledClients)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// var service = _downloadServiceFactory.GetDownloadService(client);
|
||||
// if (service != null)
|
||||
// {
|
||||
// await service.LoginAsync();
|
||||
// downloadServices.Add(service);
|
||||
// _logger.LogDebug("Initialized download client: {name}", client.Name);
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// _logger.LogWarning("Download client service not available for: {name}", client.Name);
|
||||
// }
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// _logger.LogError(ex, "Failed to initialize download client: {name}", client.Name);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (downloadServices.Count == 0)
|
||||
// {
|
||||
// _logger.LogWarning("No valid download clients found");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// _logger.LogDebug("Initialized {count} download clients", downloadServices.Count);
|
||||
// }
|
||||
//
|
||||
// return downloadServices;
|
||||
// }
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
@@ -130,9 +69,12 @@ public abstract class GenericHandler : IHandler
|
||||
ContextProvider.Set(nameof(InstanceType.Lidarr), await _dataContext.ArrConfigs.AsNoTracking()
|
||||
.Include(x => x.Instances)
|
||||
.FirstAsync(x => x.Type == InstanceType.Lidarr));
|
||||
ContextProvider.Set(nameof(InstanceType.Readarr), await _dataContext.ArrConfigs.AsNoTracking()
|
||||
.Include(x => x.Instances)
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr));
|
||||
ContextProvider.Set(nameof(QueueCleanerConfig), await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync());
|
||||
ContextProvider.Set(nameof(ContentBlockerConfig), await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync());
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync());
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.Include(x => x.Categories).AsNoTracking().FirstAsync());
|
||||
ContextProvider.Set(nameof(DownloadClientConfig), await _dataContext.DownloadClients.AsNoTracking()
|
||||
.Where(x => x.Enabled)
|
||||
.ToListAsync());
|
||||
@@ -252,6 +194,10 @@ public abstract class GenericHandler : IHandler
|
||||
{
|
||||
Id = record.AlbumId
|
||||
},
|
||||
InstanceType.Readarr => new SearchItem
|
||||
{
|
||||
Id = record.BookId
|
||||
},
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
|
||||
public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IAppriseProxy _proxy;
|
||||
|
||||
public override string Name => "Apprise";
|
||||
@@ -17,7 +15,6 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
|
||||
public AppriseProvider(DataContext dataContext, IAppriseProxy proxy)
|
||||
: base(dataContext.AppriseConfigs)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Newtonsoft.Json;
|
||||
@@ -22,12 +23,18 @@ public sealed class AppriseProxy : IAppriseProxy
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
});
|
||||
|
||||
UriBuilder uriBuilder = new(config.Url);
|
||||
UriBuilder uriBuilder = new(config.Url.ToString());
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/notify/{config.Key}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Method = HttpMethod.Post;
|
||||
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
if (!string.IsNullOrEmpty(config.Url.UserInfo))
|
||||
{
|
||||
var byteArray = Encoding.ASCII.GetBytes(config.Url.UserInfo);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray));
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record CategoryChangedNotification : Notification
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record DownloadCleanedNotification : Notification
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record FailedImportStrikeNotification : ArrNotification
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public abstract record Notification
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record NotificationField
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public enum NotificationLevel
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record QueueItemDeletedNotification : ArrNotification
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record SlowStrikeNotification : ArrNotification
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record StalledStrikeNotification : ArrNotification
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Mapster;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications;
|
||||
@@ -10,7 +10,7 @@ public abstract class NotificationProvider<T> : INotificationProvider<T>
|
||||
protected readonly DbSet<T> _notificationConfig;
|
||||
protected T? _config;
|
||||
|
||||
public T Config => _config ??= _notificationConfig.First();
|
||||
public T Config => _config ??= _notificationConfig.AsNoTracking().First();
|
||||
|
||||
NotificationConfig INotificationProvider.Config => Config;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Globalization;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Mapster;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -166,6 +166,7 @@ public class NotificationPublisher : INotificationPublisher
|
||||
InstanceType.Sonarr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
|
||||
InstanceType.Radarr => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
|
||||
InstanceType.Lidarr => record.Album?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
|
||||
InstanceType.Readarr => record.Book?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Basic application health check that verifies the application is running
|
||||
/// </summary>
|
||||
public class ApplicationHealthCheck : IHealthCheck
|
||||
{
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Basic liveness check - if we can execute this, the app is running
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Application is running"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Health check that verifies database connectivity
|
||||
/// </summary>
|
||||
public class DatabaseHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly ILogger<DatabaseHealthCheck> _logger;
|
||||
|
||||
public DatabaseHealthCheck(DataContext dataContext, ILogger<DatabaseHealthCheck> logger)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to execute a simple query to verify database connectivity
|
||||
var canConnect = await _dataContext.Database.CanConnectAsync(cancellationToken);
|
||||
|
||||
if (!canConnect)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Cannot connect to database");
|
||||
}
|
||||
|
||||
// Optionally check if database schema is up to date
|
||||
var pendingMigrations = await _dataContext.Database.GetPendingMigrationsAsync(cancellationToken);
|
||||
if (pendingMigrations.Any())
|
||||
{
|
||||
_logger.LogWarning("Database has pending migrations: {migrations}", string.Join(", ", pendingMigrations));
|
||||
return HealthCheckResult.Degraded($"Database has {pendingMigrations.Count()} pending migrations");
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("Database connection successful");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Database health check failed");
|
||||
return HealthCheckResult.Unhealthy("Database health check failed", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Health check that verifies download clients are healthy
|
||||
/// </summary>
|
||||
public class DownloadClientsHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
private readonly ILogger<DownloadClientsHealthCheck> _logger;
|
||||
|
||||
public DownloadClientsHealthCheck(IHealthCheckService healthCheckService, ILogger<DownloadClientsHealthCheck> logger)
|
||||
{
|
||||
_healthCheckService = healthCheckService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get current health status of all clients without triggering new checks
|
||||
var allClientHealth = _healthCheckService.GetAllClientHealth();
|
||||
|
||||
if (!allClientHealth.Any())
|
||||
{
|
||||
// No clients configured - this might be ok depending on the deployment
|
||||
return HealthCheckResult.Healthy("No download clients configured");
|
||||
}
|
||||
|
||||
var healthyClients = allClientHealth.Values.Where(h => h.IsHealthy).ToList();
|
||||
var unhealthyClients = allClientHealth.Values.Where(h => !h.IsHealthy).ToList();
|
||||
var totalClients = allClientHealth.Count;
|
||||
|
||||
if (unhealthyClients.Any())
|
||||
{
|
||||
var unhealthyNames = string.Join(", ", unhealthyClients.Select(c => c.ClientName));
|
||||
var message = $"{unhealthyClients.Count}/{totalClients} download clients unhealthy: {unhealthyNames}";
|
||||
|
||||
// If more than half are unhealthy, consider it unhealthy
|
||||
if (unhealthyClients.Count > totalClients / 2)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(message);
|
||||
}
|
||||
|
||||
// Otherwise, it's degraded
|
||||
return HealthCheckResult.Degraded(message);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy($"All {totalClients} download clients are healthy");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Download clients health check failed");
|
||||
return HealthCheckResult.Unhealthy("Download clients health check failed", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Health check that verifies file system access to critical directories
|
||||
/// </summary>
|
||||
public class FileSystemHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ILogger<FileSystemHealthCheck> _logger;
|
||||
|
||||
public FileSystemHealthCheck(ILogger<FileSystemHealthCheck> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var issues = new List<string>();
|
||||
|
||||
// Check config directory access
|
||||
var configPath = ConfigurationPathProvider.GetConfigPath();
|
||||
if (!CheckDirectoryAccess(configPath, "config"))
|
||||
{
|
||||
issues.Add($"Cannot access config directory: {configPath}");
|
||||
}
|
||||
|
||||
// Check current working directory
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
if (!CheckDirectoryAccess(currentDir, "working"))
|
||||
{
|
||||
issues.Add($"Cannot access working directory: {currentDir}");
|
||||
}
|
||||
|
||||
if (issues.Any())
|
||||
{
|
||||
var message = $"File system issues detected: {string.Join(", ", issues)}";
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy(message));
|
||||
}
|
||||
|
||||
return Task.FromResult(HealthCheckResult.Healthy("File system access verified"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "File system health check failed");
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("File system health check failed", ex));
|
||||
}
|
||||
}
|
||||
|
||||
private bool CheckDirectoryAccess(string path, string description)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
_logger.LogWarning("Directory does not exist: {path} ({description})", path, description);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to enumerate directory contents
|
||||
_ = Directory.GetFiles(path).Take(1).ToList();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Cannot access {description} directory: {path}", description, path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Infrastructure.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Logging;
|
||||
|
||||
// TODO remove
|
||||
public class LoggingInitializer : BackgroundService
|
||||
{
|
||||
private readonly ILogger<LoggingInitializer> _logger;
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly Random random = new();
|
||||
|
||||
public LoggingInitializer(ILogger<LoggingInitializer> logger, EventPublisher eventPublisher)
|
||||
{
|
||||
_logger = logger;
|
||||
_eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
return;
|
||||
while (true)
|
||||
{
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category,
|
||||
random.Next(0, 100) > 50 ? InstanceType.Sonarr.ToString() : InstanceType.Radarr.ToString());
|
||||
try
|
||||
{
|
||||
|
||||
await _eventPublisher.PublishAsync(
|
||||
random.Next(0, 100) > 50 ? EventType.DownloadCleaned : EventType.StalledStrike,
|
||||
"This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.",
|
||||
EventSeverity.Important,
|
||||
data: new { Hash = "hash", Name = "name", StrikeCount = "1", Type = "stalled" });
|
||||
throw new Exception("test exception");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogCritical("test critical");
|
||||
_logger.LogTrace("test trace");
|
||||
_logger.LogDebug("test debug");
|
||||
_logger.LogWarning("test warn");
|
||||
_logger.LogError(exception, "This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.");
|
||||
}
|
||||
|
||||
await Task.Delay(10000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,29 +9,3 @@ public enum JobType
|
||||
ContentBlocker,
|
||||
DownloadCleaner
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for JobType enum
|
||||
/// </summary>
|
||||
public static class JobTypeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a JobType enum to its string representation
|
||||
/// </summary>
|
||||
/// <param name="jobType">The job type to convert</param>
|
||||
/// <returns>String representation of the job type</returns>
|
||||
public static string ToJobName(this JobType jobType) => jobType.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Parses a string to JobType enum
|
||||
/// </summary>
|
||||
/// <param name="jobName">The job name to parse</param>
|
||||
/// <returns>JobType if successful, null if parsing failed</returns>
|
||||
public static JobType? TryParseJobType(string jobName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(jobName))
|
||||
return null;
|
||||
|
||||
return Enum.TryParse<JobType>(jobName, true, out var result) ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Quartz;
|
||||
|
||||
namespace Infrastructure.Services.Interfaces;
|
||||
namespace Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
|
||||
public interface IJobManagementService
|
||||
{
|
||||
@@ -8,7 +9,9 @@ public interface IJobManagementService
|
||||
Task<bool> StopJob(JobType jobType);
|
||||
Task<bool> PauseJob(JobType jobType);
|
||||
Task<bool> ResumeJob(JobType jobType);
|
||||
Task<IReadOnlyList<JobInfo>> GetAllJobs();
|
||||
Task<bool> TriggerJobOnce(JobType jobType);
|
||||
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
|
||||
Task<JobInfo> GetJob(JobType jobType);
|
||||
Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule);
|
||||
Task<ITrigger?> GetMainTrigger(JobType jobType);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Infrastructure.Services.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Quartz;
|
||||
using Quartz.Impl.Matchers;
|
||||
using Quartz.Spi;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Services;
|
||||
|
||||
@@ -22,7 +23,7 @@ public class JobManagementService : IJobManagementService
|
||||
|
||||
public async Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null)
|
||||
{
|
||||
string jobName = jobType.ToJobName();
|
||||
string jobName = jobType.ToString();
|
||||
string? cronExpression = null;
|
||||
|
||||
// Validate and set the cron expression
|
||||
@@ -59,60 +60,48 @@ public class JobManagementService : IJobManagementService
|
||||
// Check if job exists, create it if it doesn't
|
||||
if (!await scheduler.CheckExists(jobKey))
|
||||
{
|
||||
_logger.LogInformation("Job {name} does not exist, creating it", jobName);
|
||||
|
||||
// Create the job based on its type
|
||||
if (!await CreateJobIfNotExists(scheduler, jobType, jobKey))
|
||||
{
|
||||
_logger.LogError("Failed to create job {name}", jobName);
|
||||
return false;
|
||||
}
|
||||
_logger.LogError("Job {name} does not exist in scheduler. " +
|
||||
"Jobs should be created at startup by BackgroundJobManager.", jobName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store the job key for later use
|
||||
_jobKeys.TryAdd(jobName, jobKey);
|
||||
|
||||
// If cron expression is provided, update the trigger
|
||||
// Clean up all existing triggers for this job first
|
||||
await CleanupAllTriggersForJob(scheduler, jobKey);
|
||||
|
||||
// If cron expression is provided, create and schedule the main trigger
|
||||
if (!string.IsNullOrEmpty(cronExpression))
|
||||
{
|
||||
var triggerKey = new TriggerKey($"{jobName}Trigger");
|
||||
var existingTrigger = await scheduler.GetTrigger(triggerKey);
|
||||
var triggerKey = new TriggerKey($"{jobName}-trigger");
|
||||
var newTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.ForJob(jobKey)
|
||||
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
|
||||
.Build();
|
||||
|
||||
if (existingTrigger != null)
|
||||
{
|
||||
var newTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.ForJob(jobKey)
|
||||
.WithCronSchedule(cronExpression)
|
||||
.Build();
|
||||
|
||||
await scheduler.RescheduleJob(triggerKey, newTrigger);
|
||||
}
|
||||
else
|
||||
{
|
||||
var trigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.ForJob(jobKey)
|
||||
.WithCronSchedule(cronExpression)
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(trigger);
|
||||
}
|
||||
await scheduler.ScheduleJob(newTrigger);
|
||||
|
||||
// Compute next fire time for logging
|
||||
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes((IOperableTrigger)newTrigger, null, 1);
|
||||
_logger.LogInformation("Job {name} scheduled with cron expression '{cronExpression}', next run at {nextRunTime}",
|
||||
jobName, cronExpression, nextFireTimes.FirstOrDefault().LocalDateTime);
|
||||
|
||||
// Optionally trigger immediate execution for startup
|
||||
// await TriggerJobImmediately(scheduler, jobKey, "startup");
|
||||
}
|
||||
else
|
||||
{
|
||||
// If no trigger exists, create a simple one-time trigger
|
||||
var triggers = await scheduler.GetTriggersOfJob(jobKey);
|
||||
if (!triggers.Any())
|
||||
{
|
||||
var trigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{jobName}Trigger")
|
||||
.ForJob(jobKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(trigger);
|
||||
}
|
||||
// If no cron expression, create a one-time trigger to run now
|
||||
var oneTimeTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{jobName}-onetime-trigger")
|
||||
.ForJob(jobKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(oneTimeTrigger);
|
||||
_logger.LogInformation("Job {name} scheduled for immediate one-time execution", jobName);
|
||||
}
|
||||
|
||||
// Resume the job if it's paused
|
||||
@@ -128,21 +117,86 @@ public class JobManagementService : IJobManagementService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a job in the scheduler if it doesn't exist based on the job type.
|
||||
/// Note: Since this is in the Infrastructure layer, we cannot directly reference Application layer job types.
|
||||
/// Job creation is now handled at startup by BackgroundJobManager.
|
||||
/// Cleans up all existing triggers for a job to ensure a clean state
|
||||
/// </summary>
|
||||
private Task<bool> CreateJobIfNotExists(IScheduler scheduler, JobType jobType, JobKey jobKey)
|
||||
private async Task CleanupAllTriggersForJob(IScheduler scheduler, JobKey jobKey)
|
||||
{
|
||||
_logger.LogError("Job {jobName} of type {jobType} does not exist in scheduler. " +
|
||||
"Jobs should be created at startup by BackgroundJobManager, regardless of enabled status.",
|
||||
jobKey.Name, jobType);
|
||||
return Task.FromResult(false);
|
||||
try
|
||||
{
|
||||
var existingTriggers = await scheduler.GetTriggersOfJob(jobKey);
|
||||
foreach (var trigger in existingTriggers)
|
||||
{
|
||||
await scheduler.UnscheduleJob(trigger.Key);
|
||||
_logger.LogDebug("Removed existing trigger {triggerKey} for job {jobKey}",
|
||||
trigger.Key.Name, jobKey.Name);
|
||||
}
|
||||
|
||||
if (existingTriggers.Any())
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {count} existing triggers for job {jobName}",
|
||||
existingTriggers.Count, jobKey.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error cleaning up triggers for job {jobName}", jobKey.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a job immediately with a one-time trigger
|
||||
/// </summary>
|
||||
private async Task TriggerJobImmediately(IScheduler scheduler, JobKey jobKey, string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
var immediateTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{jobKey.Name}-immediate-{reason}-{DateTimeOffset.UtcNow.Ticks}")
|
||||
.ForJob(jobKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(immediateTrigger);
|
||||
_logger.LogDebug("Triggered job {jobName} immediately for reason: {reason}", jobKey.Name, reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to trigger job {jobName} immediately", jobKey.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the main scheduled trigger for a job (excludes one-time triggers)
|
||||
/// </summary>
|
||||
public async Task<ITrigger?> GetMainTrigger(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey(jobName);
|
||||
|
||||
if (!await scheduler.CheckExists(jobKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for the main trigger (follows our naming convention)
|
||||
var mainTriggerKey = new TriggerKey($"{jobName}-trigger");
|
||||
return await scheduler.GetTrigger(mainTriggerKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting main trigger for job {jobName}", jobName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<bool> StopJob(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToJobName();
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
@@ -154,12 +208,8 @@ public class JobManagementService : IJobManagementService
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unschedule all triggers for this job
|
||||
var triggers = await scheduler.GetTriggersOfJob(jobKey);
|
||||
foreach (var trigger in triggers)
|
||||
{
|
||||
await scheduler.UnscheduleJob(trigger.Key);
|
||||
}
|
||||
// Clean up all triggers for this job (reuse our centralized method)
|
||||
await CleanupAllTriggersForJob(scheduler, jobKey);
|
||||
|
||||
_logger.LogInformation("Job {name} stopped successfully", jobName);
|
||||
return true;
|
||||
@@ -173,7 +223,7 @@ public class JobManagementService : IJobManagementService
|
||||
|
||||
public async Task<bool> PauseJob(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToJobName();
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
@@ -198,7 +248,7 @@ public class JobManagementService : IJobManagementService
|
||||
|
||||
public async Task<bool> ResumeJob(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToJobName();
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
@@ -221,11 +271,11 @@ public class JobManagementService : IJobManagementService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<JobInfo>> GetAllJobs()
|
||||
public async Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
scheduler ??= await _schedulerFactory.GetScheduler();
|
||||
var result = new List<JobInfo>();
|
||||
|
||||
var jobGroups = await scheduler.GetJobGroupNames();
|
||||
@@ -283,7 +333,7 @@ public class JobManagementService : IJobManagementService
|
||||
|
||||
public async Task<JobInfo> GetJob(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToJobName();
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
@@ -339,12 +389,37 @@ public class JobManagementService : IJobManagementService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TriggerJobOnce(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey(jobName);
|
||||
|
||||
if (!await scheduler.CheckExists(jobKey))
|
||||
{
|
||||
_logger.LogError("Job {name} does not exist", jobName);
|
||||
return false;
|
||||
}
|
||||
|
||||
await TriggerJobImmediately(scheduler, jobKey, "manual");
|
||||
_logger.LogInformation("Job {name} triggered for one-time execution", jobName);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering job {jobName}", jobName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule)
|
||||
{
|
||||
if (schedule == null)
|
||||
throw new ArgumentNullException(nameof(schedule));
|
||||
|
||||
string jobName = jobType.ToJobName();
|
||||
string jobName = jobType.ToString();
|
||||
string cronExpression = schedule.ToCronExpression();
|
||||
|
||||
try
|
||||
@@ -358,24 +433,18 @@ public class JobManagementService : IJobManagementService
|
||||
return false;
|
||||
}
|
||||
|
||||
var triggerKey = new TriggerKey($"{jobName}Trigger");
|
||||
var existingTrigger = await scheduler.GetTrigger(triggerKey);
|
||||
|
||||
// Clean up all existing triggers for this job
|
||||
await CleanupAllTriggersForJob(scheduler, jobKey);
|
||||
|
||||
// Create new trigger with consistent naming
|
||||
var triggerKey = new TriggerKey($"{jobName}-trigger");
|
||||
var newTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.ForJob(jobKey)
|
||||
.WithSchedule(SimpleScheduleBuilder.RepeatSecondlyForever(10))
|
||||
.WithCronSchedule(cronExpression)
|
||||
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
|
||||
.Build();
|
||||
|
||||
if (existingTrigger != null)
|
||||
{
|
||||
await scheduler.RescheduleJob(triggerKey, newTrigger);
|
||||
}
|
||||
else
|
||||
{
|
||||
await scheduler.ScheduleJob(newTrigger);
|
||||
}
|
||||
await scheduler.ScheduleJob(newTrigger);
|
||||
|
||||
_logger.LogInformation("Job {name} schedule updated successfully to {cronExpression}", jobName, cronExpression);
|
||||
return true;
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class CronValidationHelper
|
||||
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
|
||||
.WithIdentity("ValidationTrigger")
|
||||
.StartNow()
|
||||
.WithCronSchedule(cronExpression)
|
||||
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
|
||||
.Build();
|
||||
|
||||
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
|
||||
|
||||
@@ -77,6 +77,10 @@ public class DataContext : DbContext
|
||||
{
|
||||
cp.Property(s => s.BlocklistType).HasConversion<LowercaseEnumConverter<BlocklistType>>();
|
||||
});
|
||||
entity.ComplexProperty(e => e.Readarr, cp =>
|
||||
{
|
||||
cp.Property(s => s.BlocklistType).HasConversion<LowercaseEnumConverter<BlocklistType>>();
|
||||
});
|
||||
});
|
||||
|
||||
// Configure ArrConfig -> ArrInstance relationship
|
||||
|
||||
620
code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.Designer.cs
generated
Normal file
620
code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.Designer.cs
generated
Normal file
@@ -0,0 +1,620 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20250628231105_AddReadarr")]
|
||||
partial class AddReadarr
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<short>("FailedImportMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_configs");
|
||||
|
||||
b.ToTable("arr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<Guid>("ArrConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("arr_config_id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_instances");
|
||||
|
||||
b.HasIndex("ArrConfigId")
|
||||
.HasDatabaseName("ix_arr_instances_arr_config_id");
|
||||
|
||||
b.ToTable("arr_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ignore_private");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("lidarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("radarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("readarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sonarr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
b.ToTable("content_blocker_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadCleanerConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_cleaner_config_id");
|
||||
|
||||
b.Property<double>("MaxRatio")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_ratio");
|
||||
|
||||
b.Property<double>("MaxSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_seed_time");
|
||||
|
||||
b.Property<double>("MinSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("min_seed_time");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_clean_categories");
|
||||
|
||||
b.HasIndex("DownloadCleanerConfigId")
|
||||
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
|
||||
|
||||
b.ToTable("clean_categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("UnlinkedCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_categories");
|
||||
|
||||
b.Property<bool>("UnlinkedEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_enabled");
|
||||
|
||||
b.Property<string>("UnlinkedIgnoredRootDir")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_ignored_root_dir");
|
||||
|
||||
b.Property<string>("UnlinkedTargetCategory")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_target_category");
|
||||
|
||||
b.Property<bool>("UnlinkedUseTag")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_use_tag");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_cleaner_configs");
|
||||
|
||||
b.ToTable("download_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<string>("TypeName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type_name");
|
||||
|
||||
b.Property<string>("UrlBase")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url_base");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_clients");
|
||||
|
||||
b.ToTable("download_clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("DisplaySupportBanner")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("display_support_banner");
|
||||
|
||||
b.Property<bool>("DryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("dry_run");
|
||||
|
||||
b.Property<string>("EncryptionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("encryption_key");
|
||||
|
||||
b.Property<string>("HttpCertificateValidation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("http_certificate_validation");
|
||||
|
||||
b.Property<ushort>("HttpMaxRetries")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_max_retries");
|
||||
|
||||
b.Property<ushort>("HttpTimeout")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_timeout");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<string>("LogLevel")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_level");
|
||||
|
||||
b.Property<ushort>("SearchDelay")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_delay");
|
||||
|
||||
b.Property<bool>("SearchEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_enabled");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_general_configs");
|
||||
|
||||
b.ToTable("general_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
b.ToTable("apprise_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<string>("ChannelId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notifiarr_configs");
|
||||
|
||||
b.ToTable("notifiarr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_delete_private");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_ignore_private");
|
||||
|
||||
b1.PrimitiveCollection<string>("IgnoredPatterns")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("failed_import_ignored_patterns");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_delete_private");
|
||||
|
||||
b1.Property<string>("IgnoreAboveSize")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_ignore_above_size");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_max_strikes");
|
||||
|
||||
b1.Property<double>("MaxTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("slow_max_time");
|
||||
|
||||
b1.Property<string>("MinSpeed")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_min_speed");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_delete_private");
|
||||
|
||||
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_downloading_metadata_max_strikes");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_max_strikes");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_queue_cleaner_configs");
|
||||
|
||||
b.ToTable("queue_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("ArrConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
|
||||
|
||||
b.Navigation("ArrConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
|
||||
.WithMany("Categories")
|
||||
.HasForeignKey("DownloadCleanerConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
|
||||
|
||||
b.Navigation("DownloadCleanerConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Navigation("Categories");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReadarr : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "readarr_blocklist_path",
|
||||
table: "content_blocker_configs",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "readarr_blocklist_type",
|
||||
table: "content_blocker_configs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "readarr_enabled",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "arr_configs",
|
||||
columns: new[] { "id", "failed_import_max_strikes", "type" },
|
||||
values: new object[] { new Guid("013994ea-0a5e-4eed-91b7-271f494b6259"), (short)-1, "readarr" });
|
||||
|
||||
migrationBuilder.Sql("UPDATE content_blocker_configs SET readarr_blocklist_type = 'blacklist' WHERE readarr_blocklist_type = ''");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "readarr_blocklist_path",
|
||||
table: "content_blocker_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "readarr_blocklist_type",
|
||||
table: "content_blocker_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "readarr_enabled",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
620
code/backend/Cleanuparr.Persistence/Migrations/Data/20250702192200_ChangeAppriseConfig.Designer.cs
generated
Normal file
620
code/backend/Cleanuparr.Persistence/Migrations/Data/20250702192200_ChangeAppriseConfig.Designer.cs
generated
Normal file
@@ -0,0 +1,620 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20250702192200_ChangeAppriseConfig")]
|
||||
partial class ChangeAppriseConfig
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<short>("FailedImportMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_configs");
|
||||
|
||||
b.ToTable("arr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<Guid>("ArrConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("arr_config_id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_instances");
|
||||
|
||||
b.HasIndex("ArrConfigId")
|
||||
.HasDatabaseName("ix_arr_instances_arr_config_id");
|
||||
|
||||
b.ToTable("arr_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ignore_private");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("lidarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("radarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("readarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sonarr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
b.ToTable("content_blocker_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadCleanerConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_cleaner_config_id");
|
||||
|
||||
b.Property<double>("MaxRatio")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_ratio");
|
||||
|
||||
b.Property<double>("MaxSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_seed_time");
|
||||
|
||||
b.Property<double>("MinSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("min_seed_time");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_clean_categories");
|
||||
|
||||
b.HasIndex("DownloadCleanerConfigId")
|
||||
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
|
||||
|
||||
b.ToTable("clean_categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("UnlinkedCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_categories");
|
||||
|
||||
b.Property<bool>("UnlinkedEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_enabled");
|
||||
|
||||
b.Property<string>("UnlinkedIgnoredRootDir")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_ignored_root_dir");
|
||||
|
||||
b.Property<string>("UnlinkedTargetCategory")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_target_category");
|
||||
|
||||
b.Property<bool>("UnlinkedUseTag")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_use_tag");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_cleaner_configs");
|
||||
|
||||
b.ToTable("download_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<string>("TypeName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type_name");
|
||||
|
||||
b.Property<string>("UrlBase")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url_base");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_clients");
|
||||
|
||||
b.ToTable("download_clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("DisplaySupportBanner")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("display_support_banner");
|
||||
|
||||
b.Property<bool>("DryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("dry_run");
|
||||
|
||||
b.Property<string>("EncryptionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("encryption_key");
|
||||
|
||||
b.Property<string>("HttpCertificateValidation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("http_certificate_validation");
|
||||
|
||||
b.Property<ushort>("HttpMaxRetries")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_max_retries");
|
||||
|
||||
b.Property<ushort>("HttpTimeout")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_timeout");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<string>("LogLevel")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_level");
|
||||
|
||||
b.Property<ushort>("SearchDelay")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_delay");
|
||||
|
||||
b.Property<bool>("SearchEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_enabled");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_general_configs");
|
||||
|
||||
b.ToTable("general_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("FullUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("full_url");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
b.ToTable("apprise_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<string>("ChannelId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notifiarr_configs");
|
||||
|
||||
b.ToTable("notifiarr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_delete_private");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_ignore_private");
|
||||
|
||||
b1.PrimitiveCollection<string>("IgnoredPatterns")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("failed_import_ignored_patterns");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_delete_private");
|
||||
|
||||
b1.Property<string>("IgnoreAboveSize")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_ignore_above_size");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_max_strikes");
|
||||
|
||||
b1.Property<double>("MaxTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("slow_max_time");
|
||||
|
||||
b1.Property<string>("MinSpeed")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_min_speed");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_delete_private");
|
||||
|
||||
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_downloading_metadata_max_strikes");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_max_strikes");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_queue_cleaner_configs");
|
||||
|
||||
b.ToTable("queue_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("ArrConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
|
||||
|
||||
b.Navigation("ArrConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
|
||||
.WithMany("Categories")
|
||||
.HasForeignKey("DownloadCleanerConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
|
||||
|
||||
b.Navigation("DownloadCleanerConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Navigation("Categories");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ChangeAppriseConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "url",
|
||||
table: "apprise_configs",
|
||||
newName: "full_url");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "full_url",
|
||||
table: "apprise_configs",
|
||||
newName: "url");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,24 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnName("radarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("readarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
@@ -369,6 +387,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("FullUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("full_url");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
@@ -397,10 +419,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
|
||||
@@ -26,11 +26,14 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
|
||||
public BlocklistSettings Lidarr { get; set; } = new();
|
||||
|
||||
public BlocklistSettings Readarr { get; set; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
ValidateBlocklistSettings(Sonarr, "Sonarr");
|
||||
ValidateBlocklistSettings(Radarr, "Radarr");
|
||||
ValidateBlocklistSettings(Lidarr, "Lidarr");
|
||||
ValidateBlocklistSettings(Readarr, "Readarr");
|
||||
}
|
||||
|
||||
private static void ValidateBlocklistSettings(BlocklistSettings settings, string context)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
|
||||
public sealed record AppriseConfig : NotificationConfig
|
||||
{
|
||||
public Uri? Url { get; init; }
|
||||
public string? FullUrl { get; set; }
|
||||
|
||||
public string? Key { get; init; }
|
||||
[NotMapped]
|
||||
public Uri? Url => string.IsNullOrEmpty(FullUrl) ? null : new Uri(FullUrl, UriKind.Absolute);
|
||||
|
||||
public string? Key { get; set; }
|
||||
|
||||
public override bool IsValid()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ export const routes: Routes = [
|
||||
{ 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: '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) },
|
||||
];
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ApplicationPathService {
|
||||
*/
|
||||
getDocumentationBaseUrl(): string {
|
||||
if (isDevMode()) {
|
||||
return 'http://localhost:3000';
|
||||
return 'http://localhost:3000/Cleanuparr';
|
||||
}
|
||||
|
||||
return 'https://cleanuparr.github.io/Cleanuparr';
|
||||
@@ -59,7 +59,7 @@ export class ApplicationPathService {
|
||||
*/
|
||||
buildDocumentationUrl(section: string, fieldAnchor?: string): string {
|
||||
const baseUrl = this.getDocumentationBaseUrl();
|
||||
let url = `${baseUrl}/cleanuparr/docs/configuration/${section}`;
|
||||
let url = `${baseUrl}/docs/configuration/${section}`;
|
||||
|
||||
if (fieldAnchor) {
|
||||
url += `#${fieldAnchor}`;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ContentBlockerConfig, JobSchedule as ContentBlockerJobSchedule, Schedul
|
||||
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
|
||||
import { RadarrConfig } from "../../shared/models/radarr-config.model";
|
||||
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
|
||||
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
|
||||
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
|
||||
import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-config.model";
|
||||
import { GeneralConfig } from "../../shared/models/general-config.model";
|
||||
@@ -63,7 +64,10 @@ export class ConfigurationService {
|
||||
* Update queue cleaner configuration
|
||||
*/
|
||||
updateQueueCleanerConfig(config: QueueCleanerConfig): Observable<QueueCleanerConfig> {
|
||||
config.cronExpression = this.convertJobScheduleToCron(config.jobSchedule!);
|
||||
// Generate cron expression if using basic scheduling
|
||||
if (!config.useAdvancedScheduling && config.jobSchedule) {
|
||||
config.cronExpression = this.convertJobScheduleToCron(config.jobSchedule);
|
||||
}
|
||||
return this.http.put<QueueCleanerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/queue_cleaner'), config).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error updating queue cleaner config:", error);
|
||||
@@ -112,32 +116,32 @@ export class ConfigurationService {
|
||||
*/
|
||||
private tryExtractJobScheduleFromCron(cronExpression: string): JobSchedule | undefined {
|
||||
// Patterns we support:
|
||||
// Seconds: */n * * ? * * *
|
||||
// Minutes: 0 */n * ? * * *
|
||||
// Hours: 0 0 */n ? * * *
|
||||
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
|
||||
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
|
||||
// Hours: 0 0 */n ? * * * or 0 0 0/n ? * * * (Quartz.NET format)
|
||||
try {
|
||||
const parts = cronExpression.split(" ");
|
||||
|
||||
if (parts.length !== 7) return undefined;
|
||||
|
||||
// Every n seconds
|
||||
if (parts[0].startsWith("*/") && parts[1] === "*") {
|
||||
// Every n seconds - handle both */n and 0/n formats
|
||||
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
|
||||
const seconds = parseInt(parts[0].substring(2));
|
||||
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
|
||||
return { every: seconds, type: ScheduleUnit.Seconds };
|
||||
}
|
||||
}
|
||||
|
||||
// Every n minutes
|
||||
if (parts[0] === "0" && parts[1].startsWith("*/")) {
|
||||
// Every n minutes - handle both */n and 0/n formats
|
||||
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
|
||||
const minutes = parseInt(parts[1].substring(2));
|
||||
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
|
||||
return { every: minutes, type: ScheduleUnit.Minutes };
|
||||
}
|
||||
}
|
||||
|
||||
// Every n hours
|
||||
if (parts[0] === "0" && parts[1] === "0" && parts[2].startsWith("*/")) {
|
||||
// Every n hours - handle both */n and 0/n formats
|
||||
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
|
||||
const hours = parseInt(parts[2].substring(2));
|
||||
if (!isNaN(hours) && hours > 0 && hours < 24) {
|
||||
return { every: hours, type: ScheduleUnit.Hours };
|
||||
@@ -155,31 +159,31 @@ export class ConfigurationService {
|
||||
*/
|
||||
private convertJobScheduleToCron(schedule: JobSchedule): string {
|
||||
if (!schedule || schedule.every <= 0) {
|
||||
return "0 0/5 * * * ?"; // Default: every 5 minutes
|
||||
return "0 0/5 * * * ?"; // Default: every 5 minutes (Quartz.NET format)
|
||||
}
|
||||
|
||||
switch (schedule.type) {
|
||||
case ScheduleUnit.Seconds:
|
||||
if (schedule.every < 60) {
|
||||
return `*/${schedule.every} * * ? * * *`;
|
||||
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
|
||||
}
|
||||
break;
|
||||
|
||||
case ScheduleUnit.Minutes:
|
||||
if (schedule.every < 60) {
|
||||
return `0 */${schedule.every} * ? * * *`;
|
||||
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
|
||||
}
|
||||
break;
|
||||
|
||||
case ScheduleUnit.Hours:
|
||||
if (schedule.every < 24) {
|
||||
return `0 0 */${schedule.every} ? * * *`;
|
||||
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
return "0 0/5 * * * ?";
|
||||
return "0 0/5 * * * ?"; // Default: every 5 minutes (Quartz.NET format)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,32 +192,32 @@ export class ConfigurationService {
|
||||
*/
|
||||
private tryExtractContentBlockerJobScheduleFromCron(cronExpression: string): ContentBlockerJobSchedule | undefined {
|
||||
// Patterns we support:
|
||||
// Seconds: */n * * ? * * *
|
||||
// Minutes: 0 */n * ? * * *
|
||||
// Hours: 0 0 */n ? * * *
|
||||
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
|
||||
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
|
||||
// Hours: 0 0 */n ? * * * or 0 0 0/n ? * * * (Quartz.NET format)
|
||||
try {
|
||||
const parts = cronExpression.split(" ");
|
||||
|
||||
if (parts.length !== 7) return undefined;
|
||||
|
||||
// Every n seconds
|
||||
if (parts[0].startsWith("*/") && parts[1] === "*") {
|
||||
// Every n seconds - handle both */n and 0/n formats
|
||||
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
|
||||
const seconds = parseInt(parts[0].substring(2));
|
||||
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
|
||||
return { every: seconds, type: ContentBlockerScheduleUnit.Seconds };
|
||||
}
|
||||
}
|
||||
|
||||
// Every n minutes
|
||||
if (parts[0] === "0" && parts[1].startsWith("*/")) {
|
||||
// Every n minutes - handle both */n and 0/n formats
|
||||
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
|
||||
const minutes = parseInt(parts[1].substring(2));
|
||||
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
|
||||
return { every: minutes, type: ContentBlockerScheduleUnit.Minutes };
|
||||
}
|
||||
}
|
||||
|
||||
// Every n hours
|
||||
if (parts[0] === "0" && parts[1] === "0" && parts[2].startsWith("*/")) {
|
||||
// Every n hours - handle both */n and 0/n formats
|
||||
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
|
||||
const hours = parseInt(parts[2].substring(2));
|
||||
if (!isNaN(hours) && hours > 0 && hours < 24) {
|
||||
return { every: hours, type: ContentBlockerScheduleUnit.Hours };
|
||||
@@ -231,31 +235,31 @@ export class ConfigurationService {
|
||||
*/
|
||||
private convertContentBlockerJobScheduleToCron(schedule: ContentBlockerJobSchedule): string {
|
||||
if (!schedule || schedule.every <= 0) {
|
||||
return "0 0/5 * * * ?"; // Default: every 5 minutes
|
||||
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
|
||||
}
|
||||
|
||||
switch (schedule.type) {
|
||||
case ContentBlockerScheduleUnit.Seconds:
|
||||
if (schedule.every < 60) {
|
||||
return `*/${schedule.every} * * ? * * *`;
|
||||
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
|
||||
}
|
||||
break;
|
||||
|
||||
case ContentBlockerScheduleUnit.Minutes:
|
||||
if (schedule.every < 60) {
|
||||
return `0 */${schedule.every} * ? * * *`;
|
||||
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
|
||||
}
|
||||
break;
|
||||
|
||||
case ContentBlockerScheduleUnit.Hours:
|
||||
if (schedule.every < 24) {
|
||||
return `0 0 */${schedule.every} ? * * *`;
|
||||
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
return "0 0/5 * * * ?";
|
||||
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,6 +331,29 @@ export class ConfigurationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Readarr configuration
|
||||
*/
|
||||
getReadarrConfig(): Observable<ReadarrConfig> {
|
||||
return this.http.get<ReadarrConfig>(this.ApplicationPathService.buildApiUrl('/configuration/readarr')).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error fetching Readarr config:", error);
|
||||
return throwError(() => new Error("Failed to load Readarr configuration"));
|
||||
})
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Update Readarr configuration
|
||||
*/
|
||||
updateReadarrConfig(config: {failedImportMaxStrikes: number}): Observable<any> {
|
||||
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/readarr'), config).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error updating Readarr config:", error);
|
||||
return throwError(() => new Error(error.error?.error || "Failed to update Readarr configuration"));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Download Client configuration
|
||||
*/
|
||||
@@ -500,4 +527,42 @@ export class ConfigurationService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ===== READARR INSTANCE MANAGEMENT =====
|
||||
|
||||
/**
|
||||
* Create a new Readarr instance
|
||||
*/
|
||||
createReadarrInstance(instance: CreateArrInstanceDto): Observable<ArrInstance> {
|
||||
return this.http.post<ArrInstance>(this.ApplicationPathService.buildApiUrl('/configuration/readarr/instances'), instance).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error creating Readarr instance:", error);
|
||||
return throwError(() => new Error(error.error?.error || "Failed to create Readarr instance"));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Readarr instance by ID
|
||||
*/
|
||||
updateReadarrInstance(id: string, instance: CreateArrInstanceDto): Observable<ArrInstance> {
|
||||
return this.http.put<ArrInstance>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`), instance).pipe(
|
||||
catchError((error) => {
|
||||
console.error(`Error updating Readarr instance with ID ${id}:`, error);
|
||||
return throwError(() => new Error(error.error?.error || `Failed to update Readarr instance with ID ${id}`));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Readarr instance by ID
|
||||
*/
|
||||
deleteReadarrInstance(id: string): Observable<void> {
|
||||
return this.http.delete<void>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`)).pipe(
|
||||
catchError((error) => {
|
||||
console.error(`Error deleting Readarr instance with ID ${id}:`, error);
|
||||
return throwError(() => new Error(error.error?.error || `Failed to delete Readarr instance with ID ${id}`));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class DocumentationService {
|
||||
'notifications': {
|
||||
'notifiarr.apiKey': 'notifiarr-api-key',
|
||||
'notifiarr.channelId': 'notifiarr-channel-id',
|
||||
'apprise.url': 'apprise-url',
|
||||
'apprise.fullUrl': 'apprise-url',
|
||||
'apprise.key': 'apprise-key',
|
||||
'eventTriggers': 'event-triggers'
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<p-tag [severity]="getLogSeverity(log.level)" [value]="log.level"></p-tag>
|
||||
<span class="text-xs text-color-secondary" *ngIf="log.category">{{log.category}}</span>
|
||||
</div>
|
||||
<span class="text-xs text-color-secondary">{{log.timestamp | date:'HH:mm:ss'}}</span>
|
||||
<span class="text-xs text-color-secondary">{{ log.timestamp | date: 'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
</div>
|
||||
<div class="timeline-message"
|
||||
[pTooltip]="log.message"
|
||||
@@ -112,7 +112,7 @@
|
||||
<p-tag [severity]="getEventSeverity(event.severity)" [value]="event.severity"></p-tag>
|
||||
<span class="text-xs text-color-secondary">{{formatEventType(event.eventType)}}</span>
|
||||
</div>
|
||||
<span class="text-xs text-color-secondary">{{event.timestamp | date:'HH:mm:ss'}}</span>
|
||||
<span class="text-xs text-color-secondary">{{event.timestamp | date: 'yyyy-MM-dd HH:mm:ss'}}</span>
|
||||
</div>
|
||||
<div class="timeline-message"
|
||||
[pTooltip]="event.message"
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
</div>
|
||||
<span>Lidarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-book"></i>
|
||||
</div>
|
||||
<span>Readarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-download"></i>
|
||||
|
||||
@@ -122,22 +122,22 @@ export class ContentBlockerConfigStore extends signalStore(
|
||||
*/
|
||||
generateCronExpression(schedule: JobSchedule): string {
|
||||
if (!schedule) {
|
||||
return "0/5 * * * * ?"; // Default: every 5 seconds
|
||||
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
|
||||
}
|
||||
|
||||
// Cron format: Seconds Minutes Hours Day-of-month Month Day-of-week Year
|
||||
switch (schedule.type) {
|
||||
case ScheduleUnit.Seconds:
|
||||
return `0/${schedule.every} * * ? * * *`; // Every n seconds
|
||||
return `0/${schedule.every} * * ? * * *`; // Every n seconds (Quartz.NET format)
|
||||
|
||||
case ScheduleUnit.Minutes:
|
||||
return `0 0/${schedule.every} * ? * * *`; // Every n minutes
|
||||
return `0 0/${schedule.every} * ? * * *`; // Every n minutes (Quartz.NET format)
|
||||
|
||||
case ScheduleUnit.Hours:
|
||||
return `0 0 0/${schedule.every} ? * * *`; // Every n hours
|
||||
return `0 0 0/${schedule.every} ? * * *`; // Every n hours (Quartz.NET format)
|
||||
|
||||
default:
|
||||
return "0/5 * * * * ?"; // Default: every 5 seconds
|
||||
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
|
||||
}
|
||||
}
|
||||
})),
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
<h2 class="card-title m-0">Content Blocker Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-ban text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -104,7 +101,7 @@
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="contentBlockerForm.get('cronExpression')?.hasError('required') && contentBlockerForm.get('cronExpression')?.touched" class="p-error">Cron expression is required</small>
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +137,16 @@
|
||||
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
|
||||
<!-- Sonarr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="0">
|
||||
<p-accordion-header>Sonarr Settings</p-accordion-header>
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
<i class="pi pi-chevron-up"></i>
|
||||
} @else {
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Sonarr Settings
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="sonarr">
|
||||
<div class="field-row">
|
||||
@@ -201,7 +207,16 @@
|
||||
|
||||
<!-- Radarr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="1">
|
||||
<p-accordion-header>Radarr Settings</p-accordion-header>
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
<i class="pi pi-chevron-up"></i>
|
||||
} @else {
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Radarr Settings
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="radarr">
|
||||
<div class="field-row">
|
||||
@@ -262,7 +277,16 @@
|
||||
|
||||
<!-- Lidarr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="2">
|
||||
<p-accordion-header>Lidarr Settings</p-accordion-header>
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
<i class="pi pi-chevron-up"></i>
|
||||
} @else {
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Lidarr Settings
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="lidarr">
|
||||
<div class="field-row">
|
||||
@@ -320,6 +344,76 @@
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
|
||||
<!-- Readarr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="3">
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
<i class="pi pi-chevron-up"></i>
|
||||
} @else {
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Readarr Settings
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="readarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Readarr Blocklist
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, the Readarr blocklist will be used for content filtering</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="p-error">Path is required when Readarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-select
|
||||
formControlName="blocklistType"
|
||||
[options]="[
|
||||
{ label: 'Blacklist', value: 'Blacklist' },
|
||||
{ label: 'Whitelist', value: 'Whitelist' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
<small class="form-helper-text"
|
||||
>Type of blocklist: Blacklist (block matches) or Whitelist (only allow matches)</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
</p-accordion>
|
||||
|
||||
<!-- Action buttons -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user