mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e823b8d3 | ||
|
|
f2f11e3472 | ||
|
|
a3549c80a9 | ||
|
|
2b9c347ed6 | ||
|
|
98ccee866d | ||
|
|
911849c6dd | ||
|
|
cce3bb2c4a | ||
|
|
bcc117cd0d | ||
|
|
8e20a68ae2 | ||
|
|
736c146f25 | ||
|
|
6398ef1cc6 | ||
|
|
83e6a289be | ||
|
|
5662118b01 | ||
|
|
22dfc7b40d | ||
|
|
a51e387453 | ||
|
|
c7d2ec7311 | ||
|
|
bb9ac5b67b | ||
|
|
f93494adb2 | ||
|
|
7201520411 | ||
|
|
2a1e65e1af | ||
|
|
da318c3339 | ||
|
|
7149b6243f | ||
|
|
11f5a28c04 | ||
|
|
9cc36c7a50 | ||
|
|
861c135cc6 | ||
|
|
3b0275c411 | ||
|
|
cad1b51202 | ||
|
|
f50acd29f4 | ||
|
|
af11d595d8 | ||
|
|
44994d5b21 | ||
|
|
592fd2d846 | ||
|
|
e96be1fca2 | ||
|
|
ee44e2b5ac | ||
|
|
323bfc4d2e | ||
|
|
dca45585ca | ||
|
|
8b5918d221 | ||
|
|
9c227c1f59 | ||
|
|
2ad4499a6f | ||
|
|
33a5bf9ab3 | ||
|
|
de06d1c2d3 | ||
|
|
72855bc030 | ||
|
|
b185ea6899 | ||
|
|
1e0127e97e | ||
|
|
5bdbc98d68 | ||
|
|
e1aeb3da31 | ||
|
|
283b09e8f1 | ||
|
|
b03c96249b | ||
|
|
2971445090 | ||
|
|
55c23419cd | ||
|
|
c4b9d9503a | ||
|
|
823b73d9f0 | ||
|
|
31632d25a4 | ||
|
|
c59951a39c | ||
|
|
d9140d7b5b | ||
|
|
90865a73b5 | ||
|
|
cc45233223 | ||
|
|
5d12d601ae | ||
|
|
88f40438af | ||
|
|
0a9ec06841 | ||
|
|
a0ca6ec4b8 | ||
|
|
eb6cf96470 | ||
|
|
2ca0616771 | ||
|
|
bc85144e60 | ||
|
|
236e31c841 | ||
|
|
7a15139aa6 |
14
.github/workflows/build-docker.yml
vendored
14
.github/workflows/build-docker.yml
vendored
@@ -29,6 +29,8 @@ jobs:
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
@@ -36,6 +38,12 @@ jobs:
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag=${branch#v}
|
||||
version=${branch#v}
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
# Determine if this run is for the main branch or another branch
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
@@ -58,6 +66,12 @@ jobs:
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
# set env vars
|
||||
echo "branch=$branch" >> $GITHUB_ENV
|
||||
|
||||
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
|
||||
36
.github/workflows/cloudflare-pages-blocklists.yml
vendored
Normal file
36
.github/workflows/cloudflare-pages-blocklists.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'Cloudflare/**'
|
||||
- 'blacklist'
|
||||
- 'blacklist_permissive'
|
||||
- 'whitelist'
|
||||
- 'whitelist_with_subtitles'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
cp blacklist Cloudflare/static/
|
||||
cp blacklist_permissive Cloudflare/static/
|
||||
cp whitelist Cloudflare/static/
|
||||
cp whitelist_with_subtitles Cloudflare/static/
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "Cloudflare"
|
||||
command: pages deploy . --project-name=cleanuparr
|
||||
30
.github/workflows/cloudflare-pages-status.yml
vendored
Normal file
30
.github/workflows/cloudflare-pages-status.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Create status files
|
||||
run: |
|
||||
mkdir -p status
|
||||
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
|
||||
|
||||
# Cache static files for 10 minutes
|
||||
cat > status/_headers << 'EOF'
|
||||
/*
|
||||
Cache-Control: public, max-age=600, s-maxage=600
|
||||
EOF
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "status"
|
||||
command: pages deploy . --project-name=cleanuparr-status
|
||||
7
.github/workflows/docs.yml
vendored
7
.github/workflows/docs.yml
vendored
@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -106,7 +106,7 @@ jobs:
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: Cleanuparr ${{ needs.validate.outputs.release_version }}
|
||||
name: ${{ needs.validate.outputs.release_version }}
|
||||
tag_name: ${{ needs.validate.outputs.release_version }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
make_latest: true
|
||||
|
||||
3
Cloudflare/_headers
Normal file
3
Cloudflare/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
# Cache static files for 5 minutes
|
||||
/static/*
|
||||
Cache-Control: public, max-age=300, s-maxage=300
|
||||
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
@@ -0,0 +1,2 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
24
README.md
24
README.md
@@ -15,7 +15,8 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - 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**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - 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).
|
||||
@@ -25,21 +26,24 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
## 🎯 Supported Applications
|
||||
|
||||
### *Arr Applications
|
||||
- **Sonarr** (TV Shows)
|
||||
- **Radarr** (Movies)
|
||||
- **Lidarr** (Music)
|
||||
- **Sonarr**
|
||||
- **Radarr**
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr**
|
||||
|
||||
### Download Clients
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
|
||||
### Platforms
|
||||
- **Docker** (Linux, Windows, macOS)
|
||||
- **Windows** (Native installer)
|
||||
- **macOS** (Intel & Apple Silicon)
|
||||
- **Linux** (Portable executable)
|
||||
- **Unraid** (Community Apps)
|
||||
- **Docker**
|
||||
- **Windows**
|
||||
- **macOS**
|
||||
- **Linux**
|
||||
- **Unraid**
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -55,7 +59,7 @@ docker run -d --name cleanuparr \
|
||||
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).
|
||||
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
|
||||
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
|
||||
@@ -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/*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,10 +87,6 @@ public class EventsController : ControllerBase
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
events = events
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToList();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<AppEvent>
|
||||
{
|
||||
|
||||
@@ -139,6 +139,38 @@ public static class ApiDI
|
||||
// Map SignalR hubs
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health");
|
||||
app.MapHub<AppHub>("/api/hubs/app");
|
||||
|
||||
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
|
||||
{
|
||||
var basePath = context.Request.PathBase.HasValue
|
||||
? context.Request.PathBase.Value
|
||||
: "/";
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
name = "Cleanuparr",
|
||||
short_name = "Cleanuparr",
|
||||
start_url = basePath,
|
||||
display = "standalone",
|
||||
background_color = "#ffffff",
|
||||
theme_color = "#ffffff",
|
||||
icons = new[]
|
||||
{
|
||||
new {
|
||||
src = "icons/icon-192x192.png",
|
||||
sizes = "192x192",
|
||||
type = "image/png"
|
||||
},
|
||||
new {
|
||||
src = "icons/icon-512x512.png",
|
||||
sizes = "512x512",
|
||||
type = "image/png"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Json(manifest, contentType: "application/manifest+json");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Templates;
|
||||
using Serilog.Templates.Themes;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -12,82 +7,10 @@ public static class LoggingDI
|
||||
{
|
||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
|
||||
{
|
||||
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger();
|
||||
Log.Logger = LoggingConfigManager
|
||||
.CreateLoggerConfiguration()
|
||||
.CreateLogger();
|
||||
|
||||
return builder.ClearProviders().AddSerilog();
|
||||
}
|
||||
|
||||
public static LoggerConfiguration GetDefaultLoggerConfiguration()
|
||||
{
|
||||
LoggerConfiguration logConfig = new();
|
||||
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
|
||||
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
|
||||
|
||||
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
|
||||
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
|
||||
|
||||
// Determine job name padding
|
||||
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.ContentBlocker), nameof(JobType.DownloadCleaner)];
|
||||
int jobPadding = jobNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Determine instance name padding
|
||||
List<string> categoryNames = [
|
||||
InstanceType.Sonarr.ToString(),
|
||||
InstanceType.Radarr.ToString(),
|
||||
InstanceType.Lidarr.ToString(),
|
||||
InstanceType.Readarr.ToString(),
|
||||
InstanceType.Whisparr.ToString(),
|
||||
"SYSTEM"
|
||||
];
|
||||
int catPadding = categoryNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Apply padding values to templates
|
||||
string consoleTemplate = consoleOutputTemplate
|
||||
.Replace("JOB_PAD", jobPadding.ToString())
|
||||
.Replace("CAT_PAD", catPadding.ToString());
|
||||
|
||||
string fileTemplate = fileOutputTemplate
|
||||
.Replace("JOB_PAD", jobPadding.ToString())
|
||||
.Replace("CAT_PAD", catPadding.ToString());
|
||||
|
||||
// Configure base logger with dynamic level control
|
||||
logConfig
|
||||
.MinimumLevel.Is(LogEventLevel.Information)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
|
||||
|
||||
// Create the logs directory
|
||||
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
|
||||
if (!Directory.Exists(logsPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(logsPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new Exception($"Failed to create log directory | {logsPath}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Add main log file
|
||||
logConfig.WriteTo.File(
|
||||
path: Path.Combine(logsPath, "cleanuparr-.txt"),
|
||||
formatter: new ExpressionTemplate(fileTemplate),
|
||||
fileSizeLimitBytes: 10L * 1024 * 1024,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
rollOnFileSizeLimit: true,
|
||||
shared: true
|
||||
);
|
||||
|
||||
logConfig
|
||||
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
|
||||
|
||||
return logConfig;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -15,22 +17,26 @@ public static class MainDI
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClients(configuration)
|
||||
.AddSingleton<MemoryCache>()
|
||||
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
|
||||
.AddServices()
|
||||
.AddHealthServices()
|
||||
.AddQuartzServices(configuration)
|
||||
.AddNotifications(configuration)
|
||||
.AddNotifications()
|
||||
.AddMassTransit(config =>
|
||||
{
|
||||
config.DisableUsageTelemetry();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SonarrSearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
|
||||
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||
@@ -48,7 +54,15 @@ public static class MainDI
|
||||
cfg.ReceiveEndpoint("download-remover-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 2;
|
||||
e.PrefetchCount = 2;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-hunter-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
@@ -57,7 +71,8 @@ public static class MainDI
|
||||
{
|
||||
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class NotificationsDI
|
||||
{
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
|
||||
services
|
||||
// Notification configs are now managed through ConfigManager
|
||||
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddTransient<INotificationProvider, NotifiarrProvider>()
|
||||
.AddTransient<IAppriseProxy, AppriseProxy>()
|
||||
.AddTransient<INotificationProvider, AppriseProvider>()
|
||||
.AddTransient<INotificationPublisher, NotificationPublisher>()
|
||||
.AddTransient<INotificationFactory, NotificationFactory>()
|
||||
.AddTransient<NotificationService>();
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
.AddScoped<INotificationPublisher, NotificationPublisher>()
|
||||
.AddScoped<NotificationService>();
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
using Cleanuparr.Application.Features.ContentBlocker;
|
||||
using Cleanuparr.Application.Features.BlacklistSync;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.DownloadClient;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Files;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
@@ -23,32 +27,34 @@ public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddSingleton<IEncryptionService, AesEncryptionService>()
|
||||
.AddTransient<SensitiveDataJsonConverter>()
|
||||
.AddTransient<EventsContext>()
|
||||
.AddTransient<DataContext>()
|
||||
.AddTransient<EventPublisher>()
|
||||
.AddScoped<IEncryptionService, AesEncryptionService>()
|
||||
.AddScoped<SensitiveDataJsonConverter>()
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
// API services
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddScoped<CertificateValidationService>()
|
||||
.AddScoped<SonarrClient>()
|
||||
.AddScoped<RadarrClient>()
|
||||
.AddScoped<LidarrClient>()
|
||||
.AddScoped<ReadarrClient>()
|
||||
.AddScoped<WhisparrClient>()
|
||||
.AddScoped<ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<UnixHardLinkFileService>()
|
||||
.AddScoped<WindowsHardLinkFileService>()
|
||||
.AddScoped<ArrQueueIterator>()
|
||||
.AddScoped<DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
// Core services
|
||||
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddTransient<CertificateValidationService>()
|
||||
.AddTransient<SonarrClient>()
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<LidarrClient>()
|
||||
.AddTransient<ReadarrClient>()
|
||||
.AddTransient<ArrClientFactory>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
.AddTransient<DownloadCleaner>()
|
||||
.AddTransient<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddTransient<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddTransient<UnixHardLinkFileService>()
|
||||
.AddTransient<WindowsHardLinkFileService>()
|
||||
.AddTransient<ArrQueueIterator>()
|
||||
.AddTransient<DownloadServiceFactory>()
|
||||
.AddTransient<IStriker, Striker>()
|
||||
.AddSingleton<BlocklistProvider>();
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace Cleanuparr.Api;
|
||||
|
||||
public static class HostExtensions
|
||||
{
|
||||
public static async Task<IHost> Init(this WebApplication app)
|
||||
public static async Task<IHost> InitAsync(this WebApplication app)
|
||||
{
|
||||
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
@@ -20,19 +20,25 @@ public static class HostExtensions
|
||||
|
||||
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
|
||||
|
||||
// Apply db migrations
|
||||
var eventsContext = app.Services.GetRequiredService<EventsContext>();
|
||||
return app;
|
||||
}
|
||||
|
||||
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
|
||||
{
|
||||
// Apply events db migrations
|
||||
await using var eventsContext = EventsContext.CreateStaticInstance();
|
||||
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await eventsContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
var configContext = app.Services.GetRequiredService<DataContext>();
|
||||
// Apply data db migrations
|
||||
await using var configContext = DataContext.CreateStaticInstance();
|
||||
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
return app;
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
using Cleanuparr.Application.Features.ContentBlocker;
|
||||
using Cleanuparr.Application.Features.BlacklistSync;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.DownloadClient;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Quartz;
|
||||
@@ -22,18 +26,18 @@ namespace Cleanuparr.Api.Jobs;
|
||||
public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<BackgroundJobManager> _logger;
|
||||
private IScheduler? _scheduler;
|
||||
|
||||
public BackgroundJobManager(
|
||||
ISchedulerFactory schedulerFactory,
|
||||
DataContext dataContext,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<BackgroundJobManager> logger
|
||||
)
|
||||
{
|
||||
_schedulerFactory = schedulerFactory;
|
||||
_dataContext = dataContext;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -45,12 +49,12 @@ public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting BackgroundJobManager");
|
||||
_logger.LogDebug("Starting BackgroundJobManager");
|
||||
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
|
||||
|
||||
await InitializeJobsFromConfiguration(cancellationToken);
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager started");
|
||||
_logger.LogDebug("BackgroundJobManager started");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -64,15 +68,15 @@ public class BackgroundJobManager : IHostedService
|
||||
/// </summary>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping BackgroundJobManager");
|
||||
_logger.LogDebug("Stopping BackgroundJobManager");
|
||||
|
||||
if (_scheduler != null)
|
||||
{
|
||||
// Don't shutdown the scheduler as it's managed by QuartzHostedService
|
||||
// Don't shut down the scheduler as it's managed by QuartzHostedService
|
||||
await _scheduler.Standby(cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager stopped");
|
||||
_logger.LogDebug("BackgroundJobManager stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -86,21 +90,28 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
// Get configurations from db
|
||||
QueueCleanerConfig queueCleanerConfig = await _dataContext.QueueCleanerConfigs
|
||||
QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
ContentBlockerConfig contentBlockerConfig = await _dataContext.ContentBlockerConfigs
|
||||
ContentBlockerConfig malwareBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
DownloadCleanerConfig downloadCleanerConfig = await _dataContext.DownloadCleanerConfigs
|
||||
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterContentBlockerJob(contentBlockerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -116,24 +127,24 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the QueueCleaner job and optionally adds triggers based on configuration.
|
||||
/// </summary>
|
||||
public async Task RegisterContentBlockerJob(
|
||||
public async Task RegisterMalwareBlockerJob(
|
||||
ContentBlockerConfig config,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<ContentBlocker>(cancellationToken);
|
||||
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
|
||||
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<ContentBlocker>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +159,21 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<DownloadCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<DownloadCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the BlacklistSync job and optionally adds triggers based on general configuration.
|
||||
/// </summary>
|
||||
public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<BlacklistSynchronizer>(cancellationToken);
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<BlacklistSynchronizer>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +181,9 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add triggers for an existing job.
|
||||
/// </summary>
|
||||
private async Task AddTriggersForJob<T>(
|
||||
IJobConfig config,
|
||||
string cronExpression,
|
||||
CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -186,7 +210,7 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (typeof(T) != typeof(ContentBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
|
||||
}
|
||||
@@ -224,7 +248,7 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add a job without a trigger (for chained jobs).
|
||||
/// </summary>
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -250,6 +274,6 @@ public class BackgroundJobManager : IHostedService
|
||||
// Add job to scheduler
|
||||
await _scheduler.AddJob(jobDetail, true, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Registered job {name} without trigger", typeName);
|
||||
_logger.LogDebug("Registered job {name} without trigger", typeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ public sealed class GenericJob<T> : IJob
|
||||
where T : IHandler
|
||||
{
|
||||
private readonly ILogger<GenericJob<T>> _logger;
|
||||
private readonly T _handler;
|
||||
|
||||
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_handler = handler;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
@@ -23,7 +23,9 @@ public sealed class GenericJob<T> : IJob
|
||||
|
||||
try
|
||||
{
|
||||
await _handler.ExecuteAsync();
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var handler = scope.ServiceProvider.GetRequiredService<T>();
|
||||
await handler.ExecuteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record CreateAppriseProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record CreateNotifiarrProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public abstract record CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public bool IsEnabled { get; init; } = true;
|
||||
|
||||
public bool OnFailedImportStrike { get; init; }
|
||||
|
||||
public bool OnStalledStrike { get; init; }
|
||||
|
||||
public bool OnSlowStrike { get; init; }
|
||||
|
||||
public bool OnQueueItemDeleted { get; init; }
|
||||
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record CreateNtfyProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record TestAppriseProviderDto
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record TestNotifiarrProviderDto
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record TestNtfyProviderDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record UpdateAppriseProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record UpdateNotifiarrProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record UpdateNtfyProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public class UpdateDownloadCleanerConfigDto
|
||||
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
|
||||
|
||||
public List<string> UnlinkedCategories { get; set; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CleanCategoryDto
|
||||
|
||||
@@ -2,14 +2,18 @@ using System.Runtime.InteropServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api;
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
await builder.InitAsync();
|
||||
builder.Logging.AddLogging();
|
||||
|
||||
// Fix paths for single-file deployment on macOS
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
@@ -68,14 +72,6 @@ builder.Services.AddCors(options =>
|
||||
});
|
||||
});
|
||||
|
||||
// Register services needed for logging first
|
||||
builder.Services
|
||||
.AddTransient<LoggingConfigManager>()
|
||||
.AddSingleton<SignalRLogSink>();
|
||||
|
||||
// Add logging with proper service provider
|
||||
builder.Logging.AddLogging();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
builder.Host.UseWindowsService(options =>
|
||||
@@ -130,24 +126,11 @@ if (basePath is not null)
|
||||
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
|
||||
|
||||
// Initialize the host
|
||||
await app.Init();
|
||||
await app.InitAsync();
|
||||
|
||||
// Get LoggingConfigManager (will be created if not already registered)
|
||||
var configManager = app.Services.GetRequiredService<LoggingConfigManager>();
|
||||
|
||||
// Get the dynamic level switch for controlling log levels
|
||||
var levelSwitch = configManager.GetLevelSwitch();
|
||||
|
||||
// Get the SignalRLogSink instance
|
||||
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
|
||||
|
||||
var logConfig = LoggingDI.GetDefaultLoggerConfiguration();
|
||||
logConfig.MinimumLevel.ControlledBy(levelSwitch);
|
||||
|
||||
// Add to Serilog pipeline
|
||||
logConfig.WriteTo.Sink(signalRSink);
|
||||
|
||||
Log.Logger = logConfig.CreateLogger();
|
||||
// Configure the app hub for SignalR
|
||||
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
|
||||
SignalRLogSink.Instance.SetAppHubContext(appHub);
|
||||
|
||||
// Configure health check endpoints before the API configuration
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
@@ -164,4 +147,6 @@ app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
|
||||
app.ConfigureApi();
|
||||
|
||||
await app.RunAsync();
|
||||
await app.RunAsync();
|
||||
|
||||
await Log.CloseAndFlushAsync();
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating Whisparr configuration basic settings (instances managed separately)
|
||||
/// </summary>
|
||||
public record UpdateWhisparrConfigDto
|
||||
{
|
||||
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Application.Features.BlacklistSync;
|
||||
|
||||
public sealed class BlacklistSynchronizer : IHandler
|
||||
{
|
||||
private readonly ILogger<BlacklistSynchronizer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public BlacklistSynchronizer(
|
||||
ILogger<BlacklistSynchronizer> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
FileReader fileReader,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_fileReader = fileReader;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Blacklist sync is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
|
||||
{
|
||||
_logger.LogWarning("Blacklist sync path is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
|
||||
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
string currentHash = ComputeHash(excludedFileNames);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
|
||||
|
||||
_logger.LogDebug("Blacklist synchronization completed");
|
||||
}
|
||||
|
||||
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
|
||||
{
|
||||
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
|
||||
|
||||
// Pull existing sync history for this hash
|
||||
var alreadySynced = await _dataContext.BlacklistSyncHistory
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Hash == currentHash)
|
||||
.Select(x => x.DownloadClientId)
|
||||
.ToListAsync();
|
||||
|
||||
// Only update clients not present in history for current hash
|
||||
foreach (var clientConfig in qBittorrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (alreadySynced.Contains(clientConfig.Id))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
|
||||
if (downloadService is not QBitService qBitService)
|
||||
{
|
||||
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await qBitService.LoginAsync();
|
||||
await qBitService.UpdateBlacklistAsync(excludedFileNames);
|
||||
|
||||
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
|
||||
// Insert history row marking this client as synced for current hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = currentHash,
|
||||
DownloadClientId = clientConfig.Id
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
qBitService.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string excludedFileNames)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task RemoveOldSyncDataAsync(string currentHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dataContext.BlacklistSyncHistory
|
||||
.Where(s => s.Hash != currentHash)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Data.Models.Arr.Queue;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -60,10 +59,11 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
ignoredDownloads.AddRange(ContextProvider.Get<DownloadCleanerConfig>().IgnoredDownloads);
|
||||
|
||||
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<object>>();
|
||||
|
||||
// Process each client separately
|
||||
var allDownloads = new List<object>();
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
try
|
||||
@@ -72,24 +72,24 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
var clientDownloads = await downloadService.GetSeedingDownloads();
|
||||
if (clientDownloads?.Count > 0)
|
||||
{
|
||||
allDownloads.AddRange(clientDownloads);
|
||||
downloadServiceToDownloadsMap[downloadService] = clientDownloads;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get seeding downloads from download client");
|
||||
_logger.LogError(ex, "Failed to get seeding downloads from download client {clientName}", downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (allDownloads.Count == 0)
|
||||
if (downloadServiceToDownloadsMap.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("no seeding downloads found");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogTrace("found {count} seeding downloads", allDownloads.Count);
|
||||
var totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count);
|
||||
_logger.LogTrace("found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count);
|
||||
|
||||
// List<object>? downloadsToChangeCategory = null;
|
||||
List<Tuple<IDownloadService, List<object>>> downloadServiceWithDownloads = [];
|
||||
|
||||
if (isUnlinkedEnabled)
|
||||
@@ -103,24 +103,23 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create category for download client");
|
||||
_logger.LogError(ex, "Failed to create category for download client {clientName}", downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Get downloads to change category
|
||||
foreach (var downloadService in downloadServices)
|
||||
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clientDownloads = downloadService.FilterDownloadsToChangeCategoryAsync(allDownloads, config.UnlinkedCategories);
|
||||
if (clientDownloads?.Count > 0)
|
||||
var downloadsToChangeCategory = downloadService.FilterDownloadsToChangeCategoryAsync(clientDownloads, config.UnlinkedCategories);
|
||||
if (downloadsToChangeCategory?.Count > 0)
|
||||
{
|
||||
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads));
|
||||
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToChangeCategory));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to filter downloads for category change");
|
||||
_logger.LogError(ex, "Failed to filter downloads for category change for download client {clientName}", downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +131,8 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
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);
|
||||
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true);
|
||||
|
||||
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
@@ -158,16 +158,15 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// Get downloads to clean
|
||||
downloadServiceWithDownloads = [];
|
||||
foreach (var downloadService in downloadServices)
|
||||
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clientDownloads = downloadService.FilterDownloadsToBeCleanedAsync(allDownloads, config.Categories);
|
||||
if (clientDownloads?.Count > 0)
|
||||
var downloadsToClean = downloadService.FilterDownloadsToBeCleanedAsync(clientDownloads, config.Categories);
|
||||
if (downloadsToClean?.Count > 0)
|
||||
{
|
||||
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads));
|
||||
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToClean));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -176,9 +175,6 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
}
|
||||
}
|
||||
|
||||
// release unused objects
|
||||
allDownloads = null;
|
||||
|
||||
_logger.LogInformation("found {count} potential downloads to clean", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
|
||||
// Process cleaning for each client
|
||||
|
||||
@@ -3,30 +3,29 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LogContext = Serilog.Context.LogContext;
|
||||
|
||||
namespace Cleanuparr.Application.Features.ContentBlocker;
|
||||
namespace Cleanuparr.Application.Features.MalwareBlocker;
|
||||
|
||||
public sealed class ContentBlocker : GenericHandler
|
||||
public sealed class MalwareBlocker : GenericHandler
|
||||
{
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
|
||||
public ContentBlocker(
|
||||
ILogger<ContentBlocker> logger,
|
||||
public MalwareBlocker(
|
||||
ILogger<MalwareBlocker> logger,
|
||||
DataContext dataContext,
|
||||
IMemoryCache cache,
|
||||
IBus messageBus,
|
||||
@@ -53,7 +52,7 @@ public sealed class ContentBlocker : GenericHandler
|
||||
|
||||
var config = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled)
|
||||
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled && !config.Readarr.Enabled && !config.Whisparr.Enabled)
|
||||
{
|
||||
_logger.LogWarning("No blocklists are enabled");
|
||||
return;
|
||||
@@ -65,31 +64,38 @@ public sealed class ContentBlocker : GenericHandler
|
||||
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
|
||||
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
|
||||
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
|
||||
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
|
||||
|
||||
if (config.Sonarr.Enabled)
|
||||
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
|
||||
}
|
||||
|
||||
if (config.Radarr.Enabled)
|
||||
if (config.Radarr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
|
||||
}
|
||||
|
||||
if (config.Lidarr.Enabled)
|
||||
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
|
||||
}
|
||||
|
||||
if (config.Readarr.Enabled)
|
||||
if (config.Readarr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
|
||||
}
|
||||
|
||||
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads;
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
|
||||
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
|
||||
|
||||
@@ -178,6 +184,10 @@ public sealed class ContentBlocker : GenericHandler
|
||||
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No torrent clients enabled");
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.ShouldRemove)
|
||||
@@ -201,7 +211,7 @@ public sealed class ContentBlocker : GenericHandler
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
DeleteReason.AllFilesBlocked
|
||||
result.DeleteReason
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -12,7 +12,6 @@ using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Data.Models.Arr.Queue;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -44,16 +43,19 @@ public sealed class QueueCleaner : GenericHandler
|
||||
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
|
||||
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
|
||||
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
|
||||
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
|
||||
|
||||
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
|
||||
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
|
||||
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
|
||||
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
|
||||
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads;
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
ignoredDownloads.AddRange(ContextProvider.Get<QueueCleanerConfig>().IgnoredDownloads);
|
||||
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
|
||||
|
||||
@@ -106,7 +108,7 @@ public sealed class QueueCleaner : GenericHandler
|
||||
|
||||
DownloadCheckResult downloadCheckResult = new();
|
||||
|
||||
if (record.Protocol is "torrent")
|
||||
if (record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
var torrentClients = downloadServices
|
||||
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
|
||||
@@ -140,12 +142,16 @@ public sealed class QueueCleaner : GenericHandler
|
||||
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No torrent clients enabled");
|
||||
}
|
||||
}
|
||||
|
||||
var config = ContextProvider.Get<QueueCleanerConfig>();
|
||||
|
||||
// failed import check
|
||||
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, config.FailedImport.MaxStrikes);
|
||||
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
|
||||
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.FailedImport;
|
||||
|
||||
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public record QueueListResponse
|
||||
{
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueRecord
|
||||
{
|
||||
// Sonarr
|
||||
// Sonarr and Whisparr
|
||||
public long SeriesId { get; init; }
|
||||
public long EpisodeId { get; init; }
|
||||
public long SeasonNumber { get; init; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record TrackedDownloadStatusMessage
|
||||
{
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Data.Models.Arr;
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed class SonarrSearchItem : SearchItem
|
||||
public sealed class SeriesSearchItem : SearchItem
|
||||
{
|
||||
public long SeriesId { get; set; }
|
||||
|
||||
public SonarrSearchType SearchType { get; set; }
|
||||
public SeriesSearchType SearchType { get; set; }
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not SonarrSearchItem other)
|
||||
if (obj is not SeriesSearchItem other)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -12,5 +12,5 @@ public sealed record SonarrCommand
|
||||
|
||||
public List<long>? EpisodeIds { get; set; }
|
||||
|
||||
public SonarrSearchType SearchType { get; set; }
|
||||
public SeriesSearchType SearchType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Request;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to the µTorrent Web UI API
|
||||
/// </summary>
|
||||
public sealed class UTorrentRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The API action to perform
|
||||
/// </summary>
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication token (required for CSRF protection)
|
||||
/// </summary>
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Additional parameters for the request
|
||||
/// </summary>
|
||||
public List<(string Name, string Value)> Parameters { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the query string for the API call
|
||||
/// </summary>
|
||||
/// <returns>The complete query string including token and action</returns>
|
||||
public string ToQueryString()
|
||||
{
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"token={Token}",
|
||||
Action
|
||||
};
|
||||
|
||||
foreach (var param in Parameters)
|
||||
{
|
||||
queryParams.Add($"{Uri.EscapeDataString(param.Name)}={Uri.EscapeDataString(param.Value)}");
|
||||
}
|
||||
|
||||
return string.Join("&", queryParams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new request with the specified action
|
||||
/// </summary>
|
||||
/// <param name="action">The API action</param>
|
||||
/// <param name="token">Authentication token</param>
|
||||
/// <returns>A new UTorrentRequest instance</returns>
|
||||
public static UTorrentRequest Create(string action, string token)
|
||||
{
|
||||
return new UTorrentRequest
|
||||
{
|
||||
Action = action,
|
||||
Token = token
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a parameter to the request
|
||||
/// </summary>
|
||||
/// <param name="key">Parameter name</param>
|
||||
/// <param name="value">Parameter value</param>
|
||||
/// <returns>This instance for method chaining</returns>
|
||||
public UTorrentRequest WithParameter(string key, string value)
|
||||
{
|
||||
Parameters.Add((key, value));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Specific response type for file list API calls
|
||||
/// Replaces the generic UTorrentResponse<T> for file listings
|
||||
/// </summary>
|
||||
public sealed class FileListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw file data from the API
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "files")]
|
||||
public object[]? FilesRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Torrent hash for which files are listed
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed files as strongly-typed objects
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public List<UTorrentFile> Files { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Specific response type for label list API calls
|
||||
/// Replaces the generic UTorrentResponse<T> for label listings
|
||||
/// </summary>
|
||||
public sealed class LabelListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw label data from the API
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "label")]
|
||||
public object[][]? LabelsRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed labels as string list
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public List<string> Labels { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Specific response type for torrent properties API calls
|
||||
/// Replaces the generic UTorrentResponse<T> for properties retrieval
|
||||
/// </summary>
|
||||
public sealed class PropertiesResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw properties data from the API
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "props")]
|
||||
public object[]? PropertiesRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed properties as strongly-typed object
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public UTorrentProperties Properties { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Specific response type for torrent list API calls
|
||||
/// Replaces the generic UTorrentResponse<T> for torrent listings
|
||||
/// </summary>
|
||||
public sealed class TorrentListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// µTorrent build number
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "build")]
|
||||
public int Build { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of torrent data from the API
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "torrents")]
|
||||
public object[][]? TorrentsRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label data from the API
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "label")]
|
||||
public object[][]? LabelsRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed torrents as strongly-typed objects
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public List<UTorrentItem> Torrents { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Parsed labels as string list
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public List<string> Labels { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file within a torrent from µTorrent Web UI API
|
||||
/// Based on the files array structure from the API documentation
|
||||
/// </summary>
|
||||
public sealed class UTorrentFile
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public long Size { get; set; }
|
||||
|
||||
public long Downloaded { get; set; }
|
||||
|
||||
public int Priority { get; set; }
|
||||
|
||||
public int Index { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a torrent from µTorrent Web UI API
|
||||
/// Based on the torrent array structure from the API documentation
|
||||
/// </summary>
|
||||
public sealed class UTorrentItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Torrent hash (index 0)
|
||||
/// </summary>
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Status bitfield (index 1)
|
||||
/// </summary>
|
||||
public int Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Torrent name (index 2)
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Total size in bytes (index 3)
|
||||
/// </summary>
|
||||
public long Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Progress in permille (1000 = 100%) (index 4)
|
||||
/// </summary>
|
||||
public int Progress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Downloaded bytes (index 5)
|
||||
/// </summary>
|
||||
public long Downloaded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Uploaded bytes (index 6)
|
||||
/// </summary>
|
||||
public long Uploaded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ratio * 1000 (index 7)
|
||||
/// </summary>
|
||||
public int RatioRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload speed in bytes/sec (index 8)
|
||||
/// </summary>
|
||||
public int UploadSpeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Download speed in bytes/sec (index 9)
|
||||
/// </summary>
|
||||
public int DownloadSpeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ETA in seconds (index 10)
|
||||
/// </summary>
|
||||
public int ETA { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label (index 11)
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Connected peers (index 12)
|
||||
/// </summary>
|
||||
public int PeersConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Peers in swarm (index 13)
|
||||
/// </summary>
|
||||
public int PeersInSwarm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Connected seeds (index 14)
|
||||
/// </summary>
|
||||
public int SeedsConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Seeds in swarm (index 15)
|
||||
/// </summary>
|
||||
public int SeedsInSwarm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Availability (index 16)
|
||||
/// </summary>
|
||||
public int Availability { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Queue order (index 17)
|
||||
/// </summary>
|
||||
public int QueueOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remaining bytes (index 18)
|
||||
/// </summary>
|
||||
public long Remaining { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL (index 19)
|
||||
/// </summary>
|
||||
public string DownloadUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// RSS feed URL (index 20)
|
||||
/// </summary>
|
||||
public string RssFeedUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Status message (index 21)
|
||||
/// </summary>
|
||||
public string StatusMessage { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Stream ID (index 22)
|
||||
/// </summary>
|
||||
public string StreamId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Date added as Unix timestamp (index 23)
|
||||
/// </summary>
|
||||
public long DateAdded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Date completed as Unix timestamp (index 24)
|
||||
/// </summary>
|
||||
public long DateCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// App update URL (index 25)
|
||||
/// </summary>
|
||||
public string AppUpdateUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Save path (index 26)
|
||||
/// </summary>
|
||||
public string SavePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Calculated ratio value (RatioRaw / 1000.0)
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public double Ratio => RatioRaw / 1000.0;
|
||||
|
||||
/// <summary>
|
||||
/// Progress as percentage (0.0 to 1.0)
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public double ProgressPercent => Progress / 1000.0;
|
||||
|
||||
/// <summary>
|
||||
/// Date completed as DateTime (or null if not completed)
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public DateTime? DateCompletedDateTime =>
|
||||
DateCompleted > 0 ? DateTimeOffset.FromUnixTimeSeconds(DateCompleted).DateTime : null;
|
||||
|
||||
/// <summary>
|
||||
/// Seeding time in seconds (calculated from DateCompleted to now)
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public TimeSpan? SeedingTime
|
||||
{
|
||||
get
|
||||
{
|
||||
if (DateCompletedDateTime.HasValue)
|
||||
{
|
||||
return DateTime.UtcNow - DateCompletedDateTime.Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Represents torrent properties from µTorrent Web UI API getprops action
|
||||
/// Based on the properties structure from the API documentation
|
||||
/// </summary>
|
||||
public sealed class UTorrentProperties
|
||||
{
|
||||
/// <summary>
|
||||
/// Torrent hash
|
||||
/// </summary>
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Trackers list (newlines are represented by \r\n)
|
||||
/// </summary>
|
||||
public string Trackers { get; set; } = string.Empty;
|
||||
|
||||
public List<string> TrackerList => Trackers
|
||||
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Upload limit in bytes per second
|
||||
/// </summary>
|
||||
public int UploadLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Download limit in bytes per second
|
||||
/// </summary>
|
||||
public int DownloadLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initial seeding / Super seeding
|
||||
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
|
||||
/// </summary>
|
||||
public int SuperSeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Use DHT
|
||||
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
|
||||
/// </summary>
|
||||
public int Dht { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Use PEX (Peer Exchange)
|
||||
/// -1 = Not allowed (indicates private torrent), 0 = Disabled, 1 = Enabled
|
||||
/// </summary>
|
||||
public int Pex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override queueing
|
||||
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
|
||||
/// </summary>
|
||||
public int SeedOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Seed ratio in per mils (1000 = 1.0 ratio)
|
||||
/// </summary>
|
||||
public int SeedRatio { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Seeding time in seconds
|
||||
/// 0 = No minimum seeding time
|
||||
/// </summary>
|
||||
public int SeedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload slots
|
||||
/// </summary>
|
||||
public int UploadSlots { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this torrent is private (based on PEX value)
|
||||
/// Private torrents have PEX = -1 (not allowed)
|
||||
/// </summary>
|
||||
public bool IsPrivate => Pex == -1;
|
||||
|
||||
/// <summary>
|
||||
/// Calculated seed ratio value (SeedRatio / 1000.0)
|
||||
/// </summary>
|
||||
public double SeedRatioValue => SeedRatio / 1000.0;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Base response wrapper for µTorrent Web UI API calls
|
||||
/// </summary>
|
||||
public sealed record UTorrentResponse<T>
|
||||
{
|
||||
[JsonProperty(PropertyName = "build")]
|
||||
public int Build { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "label")]
|
||||
public object[][]? Labels { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "torrents")]
|
||||
public T? Torrents { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "torrentp")]
|
||||
public object[]? TorrentProperties { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "files")]
|
||||
public object[]? FilesDto { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public List<UTorrentFile>? Files
|
||||
{
|
||||
get
|
||||
{
|
||||
if (FilesDto is null || FilesDto.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var files = new List<UTorrentFile>();
|
||||
|
||||
if (FilesDto[1] is JArray jArray)
|
||||
{
|
||||
foreach (var jToken in jArray)
|
||||
{
|
||||
var fileTokenArray = (JArray)jToken;
|
||||
var fileArray = fileTokenArray.ToObject<object[]>() ?? [];
|
||||
files.Add(new UTorrentFile
|
||||
{
|
||||
Name = fileArray[0].ToString() ?? string.Empty,
|
||||
Size = Convert.ToInt64(fileArray[1]),
|
||||
Downloaded = Convert.ToInt64(fileArray[2]),
|
||||
Priority = Convert.ToInt32(fileArray[3]),
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
}
|
||||
|
||||
[JsonProperty(PropertyName = "props")]
|
||||
public UTorrentProperties[]? Properties { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Whisparr;
|
||||
|
||||
public sealed record WhisparrCommand
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
public long? SeriesId { get; set; }
|
||||
|
||||
public long? SeasonNumber { get; set; }
|
||||
|
||||
public List<long>? EpisodeIds { get; set; }
|
||||
|
||||
public SeriesSearchType SearchType { get; set; }
|
||||
}
|
||||
@@ -11,4 +11,5 @@ public enum DeleteReason
|
||||
AllFilesSkipped,
|
||||
AllFilesSkippedByQBit,
|
||||
AllFilesBlocked,
|
||||
MalwareFileFound,
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
public enum DownloadClientTypeName
|
||||
{
|
||||
QBittorrent,
|
||||
qBittorrent,
|
||||
Deluge,
|
||||
Transmission,
|
||||
}
|
||||
uTorrent,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NotificationEventType
|
||||
{
|
||||
Test,
|
||||
FailedImportStrike,
|
||||
StalledStrike,
|
||||
SlowSpeedStrike,
|
||||
SlowTimeStrike,
|
||||
QueueItemDeleted,
|
||||
DownloadCleaned,
|
||||
CategoryChanged
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NotificationProviderType
|
||||
{
|
||||
Notifiarr,
|
||||
Apprise,
|
||||
Ntfy
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NtfyAuthenticationType
|
||||
{
|
||||
None,
|
||||
BasicAuth,
|
||||
AccessToken
|
||||
}
|
||||
10
code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs
Normal file
10
code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NtfyPriority
|
||||
{
|
||||
Min = 1,
|
||||
Low = 2,
|
||||
Default = 3,
|
||||
High = 4,
|
||||
Max = 5
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum SonarrSearchType
|
||||
public enum SeriesSearchType
|
||||
{
|
||||
Episode,
|
||||
Season,
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Deluge.Exceptions;
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public class DelugeClientException : Exception
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Deluge.Exceptions;
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public sealed class DelugeLoginException : DelugeClientException
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Deluge.Exceptions;
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public sealed class DelugeLogoutException : DelugeClientException
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when µTorrent authentication fails
|
||||
/// </summary>
|
||||
public class UTorrentAuthenticationException : UTorrentException
|
||||
{
|
||||
public UTorrentAuthenticationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public UTorrentAuthenticationException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public class UTorrentException : Exception
|
||||
{
|
||||
public UTorrentException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public UTorrentException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when µTorrent response parsing fails
|
||||
/// </summary>
|
||||
public class UTorrentParsingException : UTorrentException
|
||||
{
|
||||
/// <summary>
|
||||
/// The raw response that failed to parse
|
||||
/// </summary>
|
||||
public string RawResponse { get; }
|
||||
|
||||
public UTorrentParsingException(string message, string rawResponse) : base(message)
|
||||
{
|
||||
RawResponse = rawResponse;
|
||||
}
|
||||
|
||||
public UTorrentParsingException(string message, string rawResponse, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
RawResponse = rawResponse;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.DownloadClient;
|
||||
|
||||
public class UTorrentClientTests
|
||||
{
|
||||
private readonly UTorrentClient _client;
|
||||
private readonly Mock<HttpMessageHandler> _mockHttpHandler;
|
||||
private readonly DownloadClientConfig _config;
|
||||
private readonly Mock<IUTorrentAuthenticator> _mockAuthenticator;
|
||||
private readonly Mock<IUTorrentHttpService> _mockHttpService;
|
||||
private readonly Mock<IUTorrentResponseParser> _mockResponseParser;
|
||||
private readonly Mock<ILogger<UTorrentClient>> _mockLogger;
|
||||
|
||||
public UTorrentClientTests()
|
||||
{
|
||||
_mockHttpHandler = new Mock<HttpMessageHandler>();
|
||||
_mockAuthenticator = new Mock<IUTorrentAuthenticator>();
|
||||
_mockHttpService = new Mock<IUTorrentHttpService>();
|
||||
_mockResponseParser = new Mock<IUTorrentResponseParser>();
|
||||
_mockLogger = new Mock<ILogger<UTorrentClient>>();
|
||||
|
||||
_config = new DownloadClientConfig
|
||||
{
|
||||
Name = "test",
|
||||
Type = DownloadClientType.Torrent,
|
||||
TypeName = DownloadClientTypeName.uTorrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "password"
|
||||
};
|
||||
|
||||
_client = new UTorrentClient(
|
||||
_config,
|
||||
_mockAuthenticator.Object,
|
||||
_mockHttpService.Object,
|
||||
_mockResponseParser.Object,
|
||||
_mockLogger.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldDeserializeMixedArrayCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = new object[]
|
||||
{
|
||||
"F0616FB199B78254474AF6D72705177E71D713ED", // Hash (string)
|
||||
new object[] // File 1
|
||||
{
|
||||
"test name",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
},
|
||||
new object[] // File 2
|
||||
{
|
||||
"Dir1/Dir11/test11.zipx",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
},
|
||||
new object[] // File 3
|
||||
{
|
||||
"Dir1/sample.txt",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Equal(3, files.Count);
|
||||
|
||||
Assert.Equal("test name", files[0].Name);
|
||||
Assert.Equal(2604L, files[0].Size);
|
||||
Assert.Equal(0L, files[0].Downloaded);
|
||||
Assert.Equal(2, files[0].Priority);
|
||||
Assert.Equal(0, files[0].Index);
|
||||
|
||||
Assert.Equal("Dir1/Dir11/test11.zipx", files[1].Name);
|
||||
Assert.Equal(2604L, files[1].Size);
|
||||
Assert.Equal(0L, files[1].Downloaded);
|
||||
Assert.Equal(2, files[1].Priority);
|
||||
Assert.Equal(1, files[1].Index);
|
||||
|
||||
Assert.Equal("Dir1/sample.txt", files[2].Name);
|
||||
Assert.Equal(2604L, files[2].Size);
|
||||
Assert.Equal(0L, files[2].Downloaded);
|
||||
Assert.Equal(2, files[2].Priority);
|
||||
Assert.Equal(2, files[2].Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldHandleEmptyResponse()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = new object[]
|
||||
{
|
||||
"F0616FB199B78254474AF6D72705177E71D713ED" // Only hash, no files
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldHandleNullResponse()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = null
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Empty(files);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker;
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
|
||||
|
||||
public class FilenameEvaluatorFixture
|
||||
{
|
||||
@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker;
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
|
||||
|
||||
public class FilenameEvaluatorTests : IClassFixture<FilenameEvaluatorFixture>
|
||||
{
|
||||
@@ -7,11 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Features\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
|
||||
@@ -22,6 +18,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,15 +11,15 @@ namespace Cleanuparr.Infrastructure.Events;
|
||||
/// </summary>
|
||||
public class EventCleanupService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<EventCleanupService> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours
|
||||
private readonly int _retentionDays = 30; // Keep events for 30 days
|
||||
|
||||
public EventCleanupService(IServiceProvider serviceProvider, ILogger<EventCleanupService> logger)
|
||||
public EventCleanupService(ILogger<EventCleanupService> logger, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -58,7 +58,7 @@ public class EventCleanupService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -47,7 +49,10 @@ public class EventPublisher
|
||||
EventType = eventType,
|
||||
Message = message,
|
||||
Severity = severity,
|
||||
Data = data != null ? JsonSerializer.Serialize(data) : null,
|
||||
Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
}) : null,
|
||||
TrackingId = trackingId
|
||||
};
|
||||
|
||||
@@ -73,14 +78,40 @@ public class EventPublisher
|
||||
StrikeType.FailedImport => EventType.FailedImportStrike,
|
||||
StrikeType.SlowSpeed => EventType.SlowSpeedStrike,
|
||||
StrikeType.SlowTime => EventType.SlowTimeStrike,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
|
||||
};
|
||||
|
||||
dynamic data;
|
||||
|
||||
if (strikeType is StrikeType.FailedImport)
|
||||
{
|
||||
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
|
||||
data = new
|
||||
{
|
||||
hash,
|
||||
itemName,
|
||||
strikeCount,
|
||||
strikeType,
|
||||
failedImportReasons = record.StatusMessages ?? [],
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
data = new
|
||||
{
|
||||
hash,
|
||||
itemName,
|
||||
strikeCount,
|
||||
strikeType,
|
||||
};
|
||||
}
|
||||
|
||||
// Publish the event
|
||||
await PublishAsync(
|
||||
eventType,
|
||||
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
|
||||
EventSeverity.Important,
|
||||
data: new { hash, itemName, strikeCount, strikeType });
|
||||
data: data);
|
||||
|
||||
// Send notification (uses ContextProvider internally)
|
||||
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);
|
||||
|
||||
@@ -3,12 +3,11 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
@@ -66,7 +65,7 @@ public abstract class ArrClient : IArrClient
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes)
|
||||
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
|
||||
|
||||
@@ -106,6 +105,12 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : queueCleanerConfig.FailedImport.MaxStrikes;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Item {title} has failed import status with the following reason(s):\n{messages}",
|
||||
record.Title,
|
||||
string.Join("\n", record.StatusMessages?.Select(JsonConvert.SerializeObject) ?? [])
|
||||
);
|
||||
|
||||
return await _striker.StrikeAndCheckLimit(
|
||||
record.DownloadId,
|
||||
record.Title,
|
||||
@@ -207,7 +212,7 @@ public abstract class ArrClient : IArrClient
|
||||
return response;
|
||||
}
|
||||
|
||||
private bool HasIgnoredPatterns(QueueRecord record)
|
||||
private static bool HasIgnoredPatterns(QueueRecord record)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
|
||||
|
||||
|
||||
@@ -9,18 +9,21 @@ public sealed class ArrClientFactory
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ILidarrClient _lidarrClient;
|
||||
private readonly IReadarrClient _readarrClient;
|
||||
private readonly IWhisparrClient _whisparrClient;
|
||||
|
||||
public ArrClientFactory(
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
LidarrClient lidarrClient,
|
||||
ReadarrClient readarrClient
|
||||
ReadarrClient readarrClient,
|
||||
WhisparrClient whisparrClient
|
||||
)
|
||||
{
|
||||
_sonarrClient = sonarrClient;
|
||||
_radarrClient = radarrClient;
|
||||
_lidarrClient = lidarrClient;
|
||||
_readarrClient = readarrClient;
|
||||
_whisparrClient = whisparrClient;
|
||||
}
|
||||
|
||||
public IArrClient GetClient(InstanceType type) =>
|
||||
@@ -30,6 +33,7 @@ public sealed class ArrClientFactory
|
||||
InstanceType.Radarr => _radarrClient,
|
||||
InstanceType.Lidarr => _lidarrClient,
|
||||
InstanceType.Readarr => _readarrClient,
|
||||
InstanceType.Whisparr => _whisparrClient,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
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;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
@@ -10,7 +9,7 @@ public interface IArrClient
|
||||
{
|
||||
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
|
||||
|
||||
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes);
|
||||
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
|
||||
|
||||
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IWhisparrClient : IArrClient
|
||||
{
|
||||
}
|
||||
@@ -3,10 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Lidarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Radarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Readarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Sonarr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Series = Cleanuparr.Domain.Entities.Sonarr.Series;
|
||||
@@ -58,7 +58,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
|
||||
|
||||
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
|
||||
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Content = new StringContent(
|
||||
@@ -97,7 +97,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
}
|
||||
|
||||
private static string GetSearchLog(
|
||||
SonarrSearchType searchType,
|
||||
SeriesSearchType searchType,
|
||||
Uri instanceUrl,
|
||||
SonarrCommand command,
|
||||
bool success,
|
||||
@@ -108,22 +108,22 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
|
||||
return searchType switch
|
||||
{
|
||||
SonarrSearchType.Episode =>
|
||||
SeriesSearchType.Episode =>
|
||||
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
|
||||
SonarrSearchType.Season =>
|
||||
SeriesSearchType.Season =>
|
||||
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
|
||||
SonarrSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
|
||||
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SonarrSearchType searchType)
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SeriesSearchType searchType)
|
||||
{
|
||||
try
|
||||
{
|
||||
StringBuilder log = new();
|
||||
|
||||
if (searchType is SonarrSearchType.Episode)
|
||||
if (searchType is SeriesSearchType.Episode)
|
||||
{
|
||||
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
|
||||
|
||||
@@ -165,7 +165,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
}
|
||||
}
|
||||
|
||||
if (searchType is SonarrSearchType.Season)
|
||||
if (searchType is SeriesSearchType.Season)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
@@ -177,7 +177,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
log.Append($"[{show.Title} season {command.SeasonNumber}]");
|
||||
}
|
||||
|
||||
if (searchType is SonarrSearchType.Series)
|
||||
if (searchType is SeriesSearchType.Series)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
@@ -230,7 +230,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
return JsonConvert.DeserializeObject<Series>(responseBody);
|
||||
}
|
||||
|
||||
private List<SonarrCommand> GetSearchCommands(HashSet<SonarrSearchItem> items)
|
||||
private List<SonarrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
|
||||
{
|
||||
const string episodeSearch = "EpisodeSearch";
|
||||
const string seasonSearch = "SeasonSearch";
|
||||
@@ -238,29 +238,29 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
|
||||
List<SonarrCommand> commands = new();
|
||||
|
||||
foreach (SonarrSearchItem item in items)
|
||||
foreach (SeriesSearchItem item in items)
|
||||
{
|
||||
SonarrCommand command = item.SearchType is SonarrSearchType.Episode
|
||||
SonarrCommand command = item.SearchType is SeriesSearchType.Episode
|
||||
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
|
||||
: new();
|
||||
|
||||
switch (item.SearchType)
|
||||
{
|
||||
case SonarrSearchType.Episode when command.EpisodeIds is null:
|
||||
case SeriesSearchType.Episode when command.EpisodeIds is null:
|
||||
command.EpisodeIds = [item.Id];
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Episode when command.EpisodeIds is not null:
|
||||
case SeriesSearchType.Episode when command.EpisodeIds is not null:
|
||||
command.EpisodeIds.Add(item.Id);
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Season:
|
||||
case SeriesSearchType.Season:
|
||||
command.Name = seasonSearch;
|
||||
command.SeasonNumber = item.Id;
|
||||
command.SeriesId = ((SonarrSearchItem)item).SeriesId;
|
||||
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Series:
|
||||
case SeriesSearchType.Series:
|
||||
command.Name = seriesSearch;
|
||||
command.SeriesId = item.Id;
|
||||
break;
|
||||
@@ -269,7 +269,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
|
||||
}
|
||||
|
||||
if (item.SearchType is SonarrSearchType.Episode && commands.Count > 0)
|
||||
if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0)
|
||||
{
|
||||
// only one command will be generated for episodes search
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Sonarr;
|
||||
using Cleanuparr.Domain.Entities.Whisparr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr;
|
||||
|
||||
public class WhisparrClient : ArrClient, IWhisparrClient
|
||||
{
|
||||
public WhisparrClient(
|
||||
ILogger<WhisparrClient> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IStriker striker,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlPath()
|
||||
{
|
||||
return "/api/v3/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
|
||||
}
|
||||
|
||||
protected override string GetQueueDeleteUrlPath(long recordId)
|
||||
{
|
||||
return $"/api/v3/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;
|
||||
}
|
||||
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
|
||||
|
||||
foreach (WhisparrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Content = new StringContent(
|
||||
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType);
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
|
||||
response?.Dispose();
|
||||
|
||||
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, false, logContext));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.EpisodeId is 0 || record.SeriesId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
|
||||
private static string GetSearchLog(
|
||||
SeriesSearchType searchType,
|
||||
Uri instanceUrl,
|
||||
WhisparrCommand command,
|
||||
bool success,
|
||||
string? logContext
|
||||
)
|
||||
{
|
||||
string status = success ? "triggered" : "failed";
|
||||
|
||||
return searchType switch
|
||||
{
|
||||
SeriesSearchType.Episode =>
|
||||
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
|
||||
SeriesSearchType.Season =>
|
||||
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
|
||||
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, WhisparrCommand command, SeriesSearchType searchType)
|
||||
{
|
||||
try
|
||||
{
|
||||
StringBuilder log = new();
|
||||
|
||||
if (searchType is SeriesSearchType.Episode)
|
||||
{
|
||||
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
|
||||
|
||||
if (episodes?.Count is null or 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var seriesIds = episodes
|
||||
.Select(x => x.SeriesId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
List<Series> series = [];
|
||||
|
||||
foreach (long id in seriesIds)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, id);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
series.Add(show);
|
||||
}
|
||||
|
||||
foreach (var group in command.EpisodeIds.GroupBy(id => episodes.First(x => x.Id == id).SeriesId))
|
||||
{
|
||||
var show = series.First(x => x.Id == group.Key);
|
||||
var episode = episodes
|
||||
.Where(ep => group.Any(x => x == ep.Id))
|
||||
.OrderBy(x => x.SeasonNumber)
|
||||
.ThenBy(x => x.EpisodeNumber)
|
||||
.Select(x => $"S{x.SeasonNumber.ToString().PadLeft(2, '0')}E{x.EpisodeNumber.ToString().PadLeft(2, '0')}")
|
||||
.ToList();
|
||||
|
||||
log.Append($"[{show.Title} {string.Join(',', episode)}]");
|
||||
}
|
||||
}
|
||||
|
||||
if (searchType is SeriesSearchType.Season)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
log.Append($"[{show.Title} season {command.SeasonNumber}]");
|
||||
}
|
||||
|
||||
if (searchType is SeriesSearchType.Series)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
log.Append($"[{show.Title}]");
|
||||
}
|
||||
|
||||
return log.ToString();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to compute log context");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
|
||||
uriBuilder.Query = $"episodeIds={string.Join(',', episodeIds)}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
HttpResponseMessage response = await SendRequestAsync(request);
|
||||
string responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
return JsonConvert.DeserializeObject<List<Episode>>(responseContent);
|
||||
}
|
||||
|
||||
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
HttpResponseMessage response = await SendRequestAsync(request);
|
||||
string responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
return JsonConvert.DeserializeObject<Series>(responseContent);
|
||||
}
|
||||
|
||||
private List<WhisparrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
|
||||
{
|
||||
const string episodeSearch = "EpisodeSearch";
|
||||
const string seasonSearch = "SeasonSearch";
|
||||
const string seriesSearch = "SeriesSearch";
|
||||
|
||||
List<WhisparrCommand> commands = new();
|
||||
|
||||
foreach (SeriesSearchItem item in items)
|
||||
{
|
||||
WhisparrCommand command = item.SearchType is SeriesSearchType.Episode
|
||||
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
|
||||
: new();
|
||||
|
||||
switch (item.SearchType)
|
||||
{
|
||||
case SeriesSearchType.Episode when command.EpisodeIds is null:
|
||||
command.EpisodeIds = [item.Id];
|
||||
break;
|
||||
|
||||
case SeriesSearchType.Episode when command.EpisodeIds is not null:
|
||||
command.EpisodeIds.Add(item.Id);
|
||||
break;
|
||||
|
||||
case SeriesSearchType.Season:
|
||||
command.Name = seasonSearch;
|
||||
command.SeasonNumber = item.Id;
|
||||
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
|
||||
break;
|
||||
|
||||
case SeriesSearchType.Series:
|
||||
command.Name = seriesSearch;
|
||||
command.SeriesId = item.Id;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
|
||||
}
|
||||
|
||||
if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0)
|
||||
{
|
||||
// only one command will be generated for episodes search
|
||||
continue;
|
||||
}
|
||||
|
||||
command.SearchType = item.SearchType;
|
||||
commands.Add(command);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
|
||||
public sealed class BlocklistProvider
|
||||
{
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly Dictionary<InstanceType, string> _configHashes = new();
|
||||
private static DateTime _lastLoadTime = DateTime.MinValue;
|
||||
private const int LoadIntervalHours = 4;
|
||||
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
IServiceProvider serviceProvider,
|
||||
IMemoryCache cache,
|
||||
IHttpClientFactory httpClientFactory
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_serviceProvider = serviceProvider;
|
||||
_cache = cache;
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
public async Task LoadBlocklistsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataContext = _serviceProvider.GetRequiredService<DataContext>();
|
||||
int changedCount = 0;
|
||||
var contentBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
bool shouldReload = false;
|
||||
|
||||
if (_lastLoadTime.AddHours(LoadIntervalHours) < DateTime.UtcNow)
|
||||
{
|
||||
shouldReload = true;
|
||||
_lastLoadTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (!contentBlockerConfig.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Content blocker is disabled, skipping blocklist loading");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check and update Sonarr blocklist if needed
|
||||
string sonarrHash = GenerateSettingsHash(contentBlockerConfig.Sonarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Sonarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Sonarr, InstanceType.Sonarr);
|
||||
_configHashes[InstanceType.Sonarr] = sonarrHash;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Radarr blocklist if needed
|
||||
string radarrHash = GenerateSettingsHash(contentBlockerConfig.Radarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Radarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Radarr, InstanceType.Radarr);
|
||||
_configHashes[InstanceType.Radarr] = radarrHash;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Lidarr blocklist if needed
|
||||
string lidarrHash = GenerateSettingsHash(contentBlockerConfig.Lidarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Lidarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Lidarr, InstanceType.Lidarr);
|
||||
_configHashes[InstanceType.Lidarr] = lidarrHash;
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogTrace("All blocklists are already up to date");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load blocklists");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public BlocklistType GetBlocklistType(InstanceType instanceType)
|
||||
{
|
||||
_cache.TryGetValue(CacheKeys.BlocklistType(instanceType), out BlocklistType? blocklistType);
|
||||
|
||||
return blocklistType ?? BlocklistType.Blacklist;
|
||||
}
|
||||
|
||||
public ConcurrentBag<string> GetPatterns(InstanceType instanceType)
|
||||
{
|
||||
_cache.TryGetValue(CacheKeys.BlocklistPatterns(instanceType), out ConcurrentBag<string>? patterns);
|
||||
|
||||
return patterns ?? [];
|
||||
}
|
||||
|
||||
public ConcurrentBag<Regex> GetRegexes(InstanceType instanceType)
|
||||
{
|
||||
_cache.TryGetValue(CacheKeys.BlocklistRegexes(instanceType), out ConcurrentBag<Regex>? regexes);
|
||||
|
||||
return regexes ?? [];
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(blocklistSettings.BlocklistPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(blocklistSettings.BlocklistPath);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
const string regexId = "regex:";
|
||||
ConcurrentBag<string> patterns = [];
|
||||
ConcurrentBag<Regex> regexes = [];
|
||||
|
||||
Parallel.ForEach(filePatterns, options, pattern =>
|
||||
{
|
||||
if (!pattern.StartsWith(regexId))
|
||||
{
|
||||
patterns.Add(pattern);
|
||||
return;
|
||||
}
|
||||
|
||||
pattern = pattern[regexId.Length..];
|
||||
|
||||
try
|
||||
{
|
||||
Regex regex = new(pattern, RegexOptions.Compiled);
|
||||
regexes.Add(regex);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
_logger.LogWarning("invalid regex | {pattern}", pattern);
|
||||
}
|
||||
});
|
||||
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
|
||||
|
||||
_cache.Set(CacheKeys.BlocklistType(instanceType), blocklistSettings.BlocklistType);
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns);
|
||||
_cache.Set(CacheKeys.BlocklistRegexes(instanceType), regexes);
|
||||
|
||||
_logger.LogDebug("loaded {count} patterns", patterns.Count);
|
||||
_logger.LogDebug("loaded {count} regexes", regexes.Count);
|
||||
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistSettings.BlocklistPath);
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadContentAsync(string path)
|
||||
{
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
// http(s) url
|
||||
return await ReadFromUrlAsync(path);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// local file path
|
||||
return await File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"blocklist not found | {path}");
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadFromUrlAsync(string url)
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadAsStringAsync())
|
||||
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private string GenerateSettingsHash(BlocklistSettings blocklistSettings)
|
||||
{
|
||||
// Create a string that represents the relevant blocklist configuration
|
||||
var configStr = $"{blocklistSettings.BlocklistPath ?? string.Empty}|{blocklistSettings.BlocklistType}";
|
||||
|
||||
// Create SHA256 hash of the configuration string
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(configStr);
|
||||
var hashBytes = sha.ComputeHash(bytes);
|
||||
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
|
||||
public sealed record BlockFilesResult
|
||||
{
|
||||
@@ -13,4 +15,6 @@ public sealed record BlockFilesResult
|
||||
public bool IsPrivate { get; set; }
|
||||
|
||||
public bool Found { get; set; }
|
||||
|
||||
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.Extensions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Data.Models.Deluge.Exceptions;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ public partial class DelugeService
|
||||
|
||||
result.IsPrivate = download.Private;
|
||||
|
||||
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (contentBlockerConfig.IgnorePrivate && download.Private)
|
||||
if (malwareBlockerConfig.IgnorePrivate && download.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
@@ -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)
|
||||
@@ -69,14 +69,28 @@ public partial class DelugeService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
totalFiles++;
|
||||
int priority = file.Priority;
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
}
|
||||
|
||||
if (file.Priority is 0)
|
||||
{
|
||||
_logger.LogTrace("File is already skipped | {file}", file.Path);
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
@@ -88,9 +102,15 @@ public partial class DelugeService
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Path);
|
||||
}
|
||||
|
||||
_logger.LogTrace("File is valid | {file}", file.Path);
|
||||
priorities.Add(file.Index, priority);
|
||||
});
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!hasPriorityUpdates)
|
||||
{
|
||||
return result;
|
||||
@@ -105,8 +125,12 @@ public partial class DelugeService
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
_logger.LogDebug("All files are blocked for {name}", download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesBlocked;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
@@ -25,7 +23,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 +42,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +59,7 @@ public partial class DelugeService
|
||||
if (shouldRemove)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
_logger.LogTrace("all files are unwanted | removing download | {name}", download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
return result;
|
||||
@@ -95,11 +94,13 @@ public partial class DelugeService
|
||||
|
||||
if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogTrace("skip slow check | item is in {state} state | {name}", download.State, download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
@@ -137,6 +138,7 @@ public partial class DelugeService
|
||||
|
||||
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
@@ -149,11 +151,13 @@ public partial class DelugeService
|
||||
|
||||
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", status.State, status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (status.Eta > 0)
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is not stalled | {name}", status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@ using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Cache;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -125,7 +125,7 @@ public abstract class DownloadService : IDownloadService
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
if (!queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -145,7 +145,7 @@ public abstract class DownloadService : IDownloadService
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
if (!queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService;
|
||||
using QBitService = Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent.QBitService;
|
||||
using TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService;
|
||||
using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
|
||||
@@ -20,12 +22,13 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
/// </summary>
|
||||
public sealed class DownloadServiceFactory
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<DownloadServiceFactory> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public DownloadServiceFactory(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<DownloadServiceFactory> logger)
|
||||
ILogger<DownloadServiceFactory> logger,
|
||||
IServiceProvider serviceProvider
|
||||
)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
@@ -45,9 +48,10 @@ public sealed class DownloadServiceFactory
|
||||
|
||||
return downloadClientConfig.TypeName switch
|
||||
{
|
||||
DownloadClientTypeName.QBittorrent => CreateQBitService(downloadClientConfig),
|
||||
DownloadClientTypeName.qBittorrent => CreateQBitService(downloadClientConfig),
|
||||
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
|
||||
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
|
||||
DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig),
|
||||
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
|
||||
};
|
||||
}
|
||||
@@ -114,4 +118,26 @@ public sealed class DownloadServiceFactory
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
private UTorrentService CreateUTorrentService(DownloadClientConfig downloadClientConfig)
|
||||
{
|
||||
var logger = _serviceProvider.GetRequiredService<ILogger<UTorrentService>>();
|
||||
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
|
||||
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
|
||||
var striker = _serviceProvider.GetRequiredService<IStriker>();
|
||||
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
|
||||
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
|
||||
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
|
||||
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
|
||||
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
|
||||
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
// Create the UTorrentService instance
|
||||
UTorrentService service = new(
|
||||
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory
|
||||
);
|
||||
|
||||
return service;
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
public interface IQBitService : IDownloadService, IDisposable
|
||||
{
|
||||
Task UpdateBlacklistAsync(string blacklistPath);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
@@ -45,11 +47,11 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
try
|
||||
{
|
||||
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
|
||||
_logger.LogDebug("Successfully logged in to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Successfully logged in to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to login to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogError(ex, "Failed to login to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -65,15 +67,15 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
|
||||
if (hasCredentials)
|
||||
{
|
||||
// If credentials are provided, we must be able to login for the service to be healthy
|
||||
// If credentials are provided, we must be able to log in for the service to be healthy
|
||||
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
|
||||
_logger.LogDebug("Health check: Successfully logged in to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Health check: Successfully logged in to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If no credentials, test connectivity using version endpoint
|
||||
await _client.GetApiVersionAsync();
|
||||
_logger.LogDebug("Health check: Successfully connected to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Health check: Successfully connected to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
@@ -88,7 +90,7 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogWarning(ex, "Health check failed for QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogWarning(ex, "Health check failed for qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
|
||||
return new HealthCheckResult
|
||||
{
|
||||
@@ -98,6 +100,23 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs blacklist patterns from configured file to qBittorrent excluded file names
|
||||
/// </summary>
|
||||
/// <param name="excludedFileNames">List of excluded file names for qBittorrent</param>
|
||||
public async Task UpdateBlacklistAsync(string excludedFileNames)
|
||||
{
|
||||
Preferences preferences = new()
|
||||
{
|
||||
AdditionalData = new Dictionary<string, JToken>
|
||||
{
|
||||
{ "excluded_file_names", excludedFileNames }
|
||||
}
|
||||
};
|
||||
|
||||
await _client.SetPreferencesAsync(preferences);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QBittorrent.Client;
|
||||
|
||||
@@ -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.LogError("Failed to find torrent properties {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ public partial class QBitService
|
||||
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (contentBlockerConfig.IgnorePrivate && isPrivate)
|
||||
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
@@ -60,8 +60,9 @@ public partial class QBitService
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
if (files is null)
|
||||
if (files?.Count is null or 0)
|
||||
{
|
||||
_logger.LogDebug("skip files check | no files found | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -73,24 +74,36 @@ public partial class QBitService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
_logger.LogTrace("Skipping file with no index | {file}", file.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
_logger.LogTrace("File is already skipped | {file}", file.Name);
|
||||
totalUnwantedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
|
||||
{
|
||||
_logger.LogTrace("File is valid | {file}", file.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -101,13 +114,18 @@ public partial class QBitService
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No unwanted files found for {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
_logger.LogDebug("All files are blocked for {name}", download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesBlocked;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
|
||||
|
||||
foreach (int fileIndex in unwantedFiles)
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ public partial class QBitService
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
{
|
||||
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Seeding });
|
||||
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Completed });
|
||||
return torrentList?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
@@ -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.LogError("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.LogError("Failed to find torrent properties for {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -55,11 +55,13 @@ public partial class QBitService
|
||||
// if all files were blocked by qBittorrent
|
||||
if (download is { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
_logger.LogDebug("all files are unwanted by qBit | removing download | {name}", download.Name);
|
||||
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if all files are unwanted
|
||||
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
return result;
|
||||
}
|
||||
@@ -87,29 +89,32 @@ public partial class QBitService
|
||||
|
||||
if (queueCleanerConfig.Slow.MaxStrikes is 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | max strikes is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload))
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.State, download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
|
||||
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
{
|
||||
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
|
||||
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
@@ -134,6 +139,7 @@ public partial class QBitService
|
||||
|
||||
if (queueCleanerConfig.Stalled.MaxStrikes is 0 && queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes is 0)
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
@@ -141,6 +147,7 @@ public partial class QBitService
|
||||
and not TorrentState.ForcedFetchingMetadata)
|
||||
{
|
||||
// ignore other states
|
||||
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
@@ -149,7 +156,7 @@ public partial class QBitService
|
||||
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
|
||||
_logger.LogTrace("skip stalled check | download is private | {name}", torrent.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -168,6 +175,7 @@ public partial class QBitService
|
||||
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
|
||||
}
|
||||
|
||||
_logger.LogTrace("skip stalled check | download is not stalled | {name}", torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Transmission.API.RPC;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user