Compare commits

..

8 Commits

Author SHA1 Message Date
Flaminel
e465582a01 fixed tests 2026-01-01 23:14:27 +02:00
Flaminel
01238759b6 fixed typo 2026-01-01 21:55:14 +02:00
Flaminel
acb98db17f added migration 2026-01-01 21:03:07 +02:00
Flaminel
8c16a8b9dd added support for Whisparr v3 2026-01-01 21:03:01 +02:00
Flaminel
b71b268b08 Add configurable bind address (#404) 2025-12-31 04:40:43 +02:00
Flaminel
a708d22b27 Add GitAds to README (#403) 2025-12-31 03:35:35 +02:00
Flaminel
a9a3b08ad6 Add item name for dashboard events (#402) 2025-12-31 02:35:55 +02:00
Flaminel
1d1e8679e4 Add supported apps version disclaimer (#399) 2025-12-30 00:26:16 +02:00
73 changed files with 2239 additions and 727 deletions

View File

@@ -76,7 +76,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: 9.0.x
- name: Cache NuGet packages
uses: actions/cache@v4

View File

@@ -86,7 +86,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: 9.0.x
- name: Restore .NET dependencies
run: |

View File

@@ -70,7 +70,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: 9.0.x
- name: Restore .NET dependencies
run: |

View File

@@ -31,7 +31,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: 9.0.x
- name: Cache NuGet packages
uses: actions/cache@v4

View File

@@ -19,7 +19,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
- [Node.js 18+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations

View File

@@ -28,20 +28,23 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
## Sponsored by GitAds
[![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=cleanuparr/cleanuparr@github)](https://gitads.dev/v1/ad-track?source=cleanuparr/cleanuparr@github)
## Screenshots
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
## 🎯 Supported Applications
### *Arr Applications
### *Arr Applications (latest version)
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr**
- **Whisparr v2**
### Download Clients
### Download Clients (latest version)
- **qBittorrent**
- **Transmission**
- **Deluge**
@@ -116,4 +119,4 @@ Special thanks for inspiration go to:
# Buy me a coffee
If I made your life just a tiny bit easier, consider buying me a coffee!
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>

View File

@@ -15,7 +15,7 @@ COPY frontend/ .
RUN npm run build
# Build .NET backend
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
ARG TARGETARCH
ARG VERSION=0.0.1
ARG PACKAGES_USERNAME
@@ -42,7 +42,7 @@ RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
/p:DebugSymbols=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
# Install required packages for user management, timezone support, and Python for Apprise CLI
RUN apt-get update && apt-get install -y \

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<AssemblyName>Cleanuparr</AssemblyName>
<Version Condition="'$(Version)' == ''">0.0.1</Version>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishReadyToRun>true</PublishReadyToRun>
@@ -24,14 +24,14 @@
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.5.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />

View File

@@ -1,8 +1,6 @@
using System.Diagnostics;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -176,7 +174,7 @@ public class StatusController : ControllerBase
{
try
{
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
@@ -208,7 +206,7 @@ public class StatusController : ControllerBase
{
try
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
@@ -240,7 +238,7 @@ public class StatusController : ControllerBase
{
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new

View File

@@ -34,7 +34,8 @@ public static class ServicesDI
.AddScoped<IRadarrClient, RadarrClient>()
.AddScoped<ILidarrClient, LidarrClient>()
.AddScoped<IReadarrClient, ReadarrClient>()
.AddScoped<IWhisparrClient, WhisparrClient>()
.AddScoped<IWhisparrV2Client, WhisparrV2Client>()
.AddScoped<IWhisparrV3Client, WhisparrV3Client>()
.AddScoped<IArrClientFactory, ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<BlacklistSynchronizer>()

View File

@@ -18,6 +18,9 @@ public sealed record ArrInstanceRequest
[Required]
public required string ApiKey { get; init; }
[Required]
public required float Version { get; init; }
public ArrInstance ToEntity(Guid configId) => new()
{
Enabled = Enabled,
@@ -25,6 +28,7 @@ public sealed record ArrInstanceRequest
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = configId,
Version = Version,
};
public void ApplyTo(ArrInstance instance)
@@ -33,5 +37,6 @@ public sealed record ArrInstanceRequest
instance.Name = Name;
instance.Url = new Uri(Url);
instance.ApiKey = ApiKey;
instance.Version = Version;
}
}

View File

@@ -12,6 +12,9 @@ public sealed record TestArrInstanceRequest
[Required]
public required string ApiKey { get; init; }
[Required]
public required float Version { get; init; }
public ArrInstance ToTestInstance() => new()
{
@@ -20,5 +23,6 @@ public sealed record TestArrInstanceRequest
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = Guid.Empty,
Version = Version,
};
}

View File

@@ -1,17 +1,11 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.Arr.Controllers;
@@ -289,7 +283,7 @@ public sealed class ArrConfigController : ControllerBase
try
{
var testInstance = request.ToTestInstance();
var client = _arrClientFactory.GetClient(type);
var client = _arrClientFactory.GetClient(type, request.Version);
await client.HealthCheckAsync(testInstance);
return Ok(new { Message = $"Connection to {type} instance successful" });

View File

@@ -1,3 +1,4 @@
using System.Net;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Cleanuparr.Api;
@@ -33,12 +34,24 @@ builder.Configuration
int.TryParse(builder.Configuration.GetValue<string>("PORT"), out int port);
port = port is 0 ? 11011 : port;
string? bindAddress = builder.Configuration.GetValue<string>("BIND_ADDRESS");
if (!builder.Environment.IsDevelopment())
{
// If no port is configured, default to 11011
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(port);
if (string.IsNullOrEmpty(bindAddress) || bindAddress is "0.0.0.0" || bindAddress is "*")
{
options.ListenAnyIP(port);
}
else if (IPAddress.TryParse(bindAddress, out var ipAddress))
{
options.Listen(ipAddress, port);
}
else
{
throw new ArgumentException($"Invalid BIND_ADDRESS: '{bindAddress}'");
}
});
}
@@ -124,7 +137,7 @@ if (basePath is not null)
}
}
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
logger.LogInformation("Server configuration: BIND_ADDRESS={bindAddress}, PORT={port}, BASE_PATH={basePath}", bindAddress ?? "0.0.0.0", port, basePath ?? "/");
// Initialize the host
app.Init();

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

View File

@@ -2,17 +2,17 @@ namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueRecord
{
// Sonarr and Whisparr
// Sonarr and Whisparr v2
public long SeriesId { get; init; }
public long EpisodeId { get; init; }
public long SeasonNumber { get; init; }
public QueueSeries? Series { get; init; }
// Radarr
// Radarr and Whisparr v3
public long MovieId { get; init; }
public QueueSeries? Movie { get; init; }
public QueueMovie? Movie { get; init; }
// Lidarr
public long ArtistId { get; init; }

View File

@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Domain.Entities.Whisparr;
public sealed record WhisparrCommand
public sealed record WhisparrV2Command
{
public string Name { get; set; }

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Whisparr;
public sealed record WhisparrV3Command
{
public required string Name { get; init; }
public required List<long> MovieIds { get; init; }
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -19,9 +19,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
@@ -31,7 +31,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@@ -12,7 +12,8 @@ public class ArrClientFactoryTests
private readonly Mock<IRadarrClient> _radarrClientMock;
private readonly Mock<ILidarrClient> _lidarrClientMock;
private readonly Mock<IReadarrClient> _readarrClientMock;
private readonly Mock<IWhisparrClient> _whisparrClientMock;
private readonly Mock<IWhisparrV2Client> _whisparrClientMock;
private readonly Mock<IWhisparrV3Client> _whisparrV3ClientMock;
private readonly ArrClientFactory _factory;
public ArrClientFactoryTests()
@@ -21,14 +22,16 @@ public class ArrClientFactoryTests
_radarrClientMock = new Mock<IRadarrClient>();
_lidarrClientMock = new Mock<ILidarrClient>();
_readarrClientMock = new Mock<IReadarrClient>();
_whisparrClientMock = new Mock<IWhisparrClient>();
_whisparrClientMock = new Mock<IWhisparrV2Client>();
_whisparrV3ClientMock = new Mock<IWhisparrV3Client>();
_factory = new ArrClientFactory(
_sonarrClientMock.Object,
_radarrClientMock.Object,
_lidarrClientMock.Object,
_readarrClientMock.Object,
_whisparrClientMock.Object
_whisparrClientMock.Object,
_whisparrV3ClientMock.Object
);
}
@@ -38,7 +41,7 @@ public class ArrClientFactoryTests
public void GetClient_Sonarr_ReturnsSonarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Sonarr);
var result = _factory.GetClient(InstanceType.Sonarr, 0);
// Assert
Assert.Same(_sonarrClientMock.Object, result);
@@ -48,7 +51,7 @@ public class ArrClientFactoryTests
public void GetClient_Radarr_ReturnsRadarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Radarr);
var result = _factory.GetClient(InstanceType.Radarr, 0);
// Assert
Assert.Same(_radarrClientMock.Object, result);
@@ -58,7 +61,7 @@ public class ArrClientFactoryTests
public void GetClient_Lidarr_ReturnsLidarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Lidarr);
var result = _factory.GetClient(InstanceType.Lidarr, 0);
// Assert
Assert.Same(_lidarrClientMock.Object, result);
@@ -68,7 +71,7 @@ public class ArrClientFactoryTests
public void GetClient_Readarr_ReturnsReadarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Readarr);
var result = _factory.GetClient(InstanceType.Readarr, 0);
// Assert
Assert.Same(_readarrClientMock.Object, result);
@@ -78,12 +81,22 @@ public class ArrClientFactoryTests
public void GetClient_Whisparr_ReturnsWhisparrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Whisparr);
var result = _factory.GetClient(InstanceType.Whisparr, 2);
// Assert
Assert.Same(_whisparrClientMock.Object, result);
}
[Fact]
public void GetClient_WhisparrV3_ReturnsWhisparrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Whisparr, 3);
// Assert
Assert.Same(_whisparrV3ClientMock.Object, result);
}
[Fact]
public void GetClient_UnsupportedType_ThrowsNotImplementedException()
{
@@ -91,21 +104,17 @@ public class ArrClientFactoryTests
var unsupportedType = (InstanceType)999;
// Act & Assert
var exception = Assert.Throws<NotImplementedException>(() => _factory.GetClient(unsupportedType));
var exception = Assert.Throws<NotImplementedException>(() => _factory.GetClient(unsupportedType, It.IsAny<float>()));
Assert.Contains("not yet supported", exception.Message);
Assert.Contains("999", exception.Message);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public void GetClient_AllSupportedTypes_ReturnsNonNullClient(InstanceType instanceType)
[MemberData(nameof(InstancesData))]
public void GetClient_AllSupportedTypes_ReturnsNonNullClient(InstanceType instanceType, float? version)
{
// Act
var result = _factory.GetClient(instanceType);
var result = _factory.GetClient(instanceType, version ?? 0f);
// Assert
Assert.NotNull(result);
@@ -113,20 +122,26 @@ public class ArrClientFactoryTests
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public void GetClient_CalledMultipleTimes_ReturnsSameInstance(InstanceType instanceType)
[MemberData(nameof(InstancesData))]
public void GetClient_CalledMultipleTimes_ReturnsSameInstance(InstanceType instanceType, float? version)
{
// Act
var result1 = _factory.GetClient(instanceType);
var result2 = _factory.GetClient(instanceType);
var result1 = _factory.GetClient(instanceType, version ?? 0f);
var result2 = _factory.GetClient(instanceType, version ?? 0f);
// Assert
Assert.Same(result1, result2);
}
public static IEnumerable<object?[]> InstancesData =>
[
[InstanceType.Sonarr, null],
[InstanceType.Radarr, null],
[InstanceType.Lidarr, null],
[InstanceType.Readarr, null],
[InstanceType.Whisparr, 2f],
[InstanceType.Whisparr, 3f]
];
#endregion
}

View File

@@ -42,7 +42,7 @@ public class DownloadHunterTests : IDisposable
_fakeTimeProvider = new FakeTimeProvider();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(_arrClientMock.Object);
_downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter(
@@ -71,7 +71,7 @@ public class DownloadHunterTests : IDisposable
await _downloadHunter.HuntDownloadsAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>()), Times.Never);
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()), Times.Never);
_arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()), Times.Never);
}
@@ -107,7 +107,7 @@ public class DownloadHunterTests : IDisposable
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType), Times.Once);
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType, It.IsAny<float>()), Times.Once);
}
[Fact]
@@ -148,7 +148,7 @@ public class DownloadHunterTests : IDisposable
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
}
#endregion
@@ -292,7 +292,8 @@ public class DownloadHunterTests : IDisposable
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key"
ApiKey = "test-api-key",
Version = 0
};
}

View File

@@ -44,7 +44,7 @@ public class QueueItemRemoverTests : IDisposable
_arrClientMock = new Mock<IArrClient>();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(_arrClientMock.Object);
// Create real EventPublisher with mocked dependencies
@@ -205,7 +205,7 @@ public class QueueItemRemoverTests : IDisposable
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
}
#endregion

View File

@@ -249,7 +249,7 @@ public class DownloadCleanerTests : IDisposable
// Setup arr client to return queue record with matching download ID
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -322,7 +322,7 @@ public class DownloadCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -340,11 +340,11 @@ public class DownloadCleanerTests : IDisposable
// Assert - both instances should be processed
_fixture.ArrClientFactory.Verify(
x => x.GetClient(InstanceType.Sonarr),
x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()),
Times.Once
);
_fixture.ArrClientFactory.Verify(
x => x.GetClient(InstanceType.Radarr),
x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()),
Times.Once
);
}
@@ -502,7 +502,7 @@ public class DownloadCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecords = new List<QueueRecord>
@@ -878,7 +878,7 @@ public class DownloadCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
// Make the arr queue iterator throw an exception

View File

@@ -109,7 +109,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -139,7 +139,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -156,7 +156,7 @@ public class MalwareBlockerTests : IDisposable
await sut.ExecuteAsync();
// Assert
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
}
[Fact]
@@ -176,7 +176,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -193,8 +193,8 @@ public class MalwareBlockerTests : IDisposable
await sut.ExecuteAsync();
// Assert - Sonarr and Radarr processed because DeleteKnownMalware is true
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), Times.Once);
}
#endregion
@@ -217,7 +217,7 @@ public class MalwareBlockerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -269,7 +269,7 @@ public class MalwareBlockerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -327,7 +327,7 @@ public class MalwareBlockerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -403,7 +403,7 @@ public class MalwareBlockerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -474,7 +474,7 @@ public class MalwareBlockerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -542,7 +542,7 @@ public class MalwareBlockerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord

View File

@@ -62,7 +62,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -122,7 +122,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -182,7 +182,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -199,8 +199,8 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), Times.Once);
}
#endregion
@@ -222,7 +222,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -277,7 +277,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -334,7 +334,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -391,7 +391,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -466,7 +466,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -535,7 +535,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -603,7 +603,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -677,7 +677,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -746,7 +746,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr))
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -835,7 +835,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr))
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -907,7 +907,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Lidarr))
.Setup(x => x.GetClient(InstanceType.Lidarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -979,7 +979,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Readarr))
.Setup(x => x.GetClient(InstanceType.Readarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord

View File

@@ -58,8 +58,9 @@ public class NotificationPublisherTests
};
ContextProvider.Set(nameof(QueueRecord), record);
ContextProvider.Set(nameof(InstanceType), (object)instanceType);
ContextProvider.Set(nameof(InstanceType), instanceType);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://sonarr.local"));
ContextProvider.Set("version", 1f);
}
private void SetupDownloadCleanerContext()

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -13,15 +13,15 @@
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.1" />
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -9,31 +9,35 @@ public sealed class ArrClientFactory : IArrClientFactory
private readonly IRadarrClient _radarrClient;
private readonly ILidarrClient _lidarrClient;
private readonly IReadarrClient _readarrClient;
private readonly IWhisparrClient _whisparrClient;
private readonly IWhisparrV2Client _whisparrV2Client;
private readonly IWhisparrV3Client _whisparrV3Client;
public ArrClientFactory(
ISonarrClient sonarrClient,
IRadarrClient radarrClient,
ILidarrClient lidarrClient,
IReadarrClient readarrClient,
IWhisparrClient whisparrClient
IWhisparrV2Client whisparrV2Client,
IWhisparrV3Client whisparrV3Client
)
{
_sonarrClient = sonarrClient;
_radarrClient = radarrClient;
_lidarrClient = lidarrClient;
_readarrClient = readarrClient;
_whisparrClient = whisparrClient;
_whisparrV2Client = whisparrV2Client;
_whisparrV3Client = whisparrV3Client;
}
public IArrClient GetClient(InstanceType type) =>
public IArrClient GetClient(InstanceType type, float instanceVersion) =>
type switch
{
InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient,
InstanceType.Lidarr => _lidarrClient,
InstanceType.Readarr => _readarrClient,
InstanceType.Whisparr => _whisparrClient,
InstanceType.Whisparr when instanceVersion is 2 => _whisparrV2Client,
InstanceType.Whisparr when instanceVersion is 3 => _whisparrV3Client,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -2,14 +2,6 @@ using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Sonarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateSonarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}
/// <summary>
/// DTO for Arr instances that can handle both existing (with ID) and new (without ID) instances
/// </summary>
@@ -22,6 +14,8 @@ public record ArrInstanceDto
public bool Enabled { get; init; } = true;
public float Version { get; set; }
[Required]
public required string Name { get; init; }

View File

@@ -1,9 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Lidarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateLidarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,9 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Radarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateRadarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,9 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Readarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateReadarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,9 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Whisparr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateWhisparrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -4,5 +4,5 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrClientFactory
{
IArrClient GetClient(InstanceType type);
IArrClient GetClient(InstanceType type, float instanceVersion);
}

View File

@@ -1,5 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IWhisparrClient : IArrClient
public interface IWhisparrV2Client : IArrClient
{
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IWhisparrV3Client : IArrClient
{
}

View File

@@ -14,10 +14,10 @@ using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Arr;
public class WhisparrClient : ArrClient, IWhisparrClient
public class WhisparrV2Client : ArrClient, IWhisparrV2Client
{
public WhisparrClient(
ILogger<WhisparrClient> logger,
public WhisparrV2Client(
ILogger<WhisparrV2Client> logger,
IHttpClientFactory httpClientFactory,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
@@ -63,7 +63,7 @@ public class WhisparrClient : ArrClient, IWhisparrClient
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (WhisparrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
foreach (WhisparrV2Command command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
{
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
@@ -104,7 +104,7 @@ public class WhisparrClient : ArrClient, IWhisparrClient
private static string GetSearchLog(
SeriesSearchType searchType,
Uri instanceUrl,
WhisparrCommand command,
WhisparrV2Command v2Command,
bool success,
string? logContext
)
@@ -114,15 +114,15 @@ public class WhisparrClient : ArrClient, IWhisparrClient
return searchType switch
{
SeriesSearchType.Episode =>
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', v2Command.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}"}",
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {v2Command.SeasonNumber} series id: {v2Command.SeriesId}"}",
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {v2Command.SeriesId}"}",
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
};
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, WhisparrCommand command, SeriesSearchType searchType)
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, WhisparrV2Command v2Command, SeriesSearchType searchType)
{
try
{
@@ -130,7 +130,7 @@ public class WhisparrClient : ArrClient, IWhisparrClient
if (searchType is SeriesSearchType.Episode)
{
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
var episodes = await GetEpisodesAsync(arrInstance, v2Command.EpisodeIds);
if (episodes?.Count is null or 0)
{
@@ -156,7 +156,7 @@ public class WhisparrClient : ArrClient, IWhisparrClient
series.Add(show);
}
foreach (var group in command.EpisodeIds.GroupBy(id => episodes.First(x => x.Id == id).SeriesId))
foreach (var group in v2Command.EpisodeIds.GroupBy(id => episodes.First(x => x.Id == id).SeriesId))
{
var show = series.First(x => x.Id == group.Key);
var episode = episodes
@@ -172,19 +172,19 @@ public class WhisparrClient : ArrClient, IWhisparrClient
if (searchType is SeriesSearchType.Season)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
Series? show = await GetSeriesAsync(arrInstance, v2Command.SeriesId.Value);
if (show is null)
{
return null;
}
log.Append($"[{show.Title} season {command.SeasonNumber}]");
log.Append($"[{show.Title} season {v2Command.SeasonNumber}]");
}
if (searchType is SeriesSearchType.Series)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
Series? show = await GetSeriesAsync(arrInstance, v2Command.SeriesId.Value);
if (show is null)
{
@@ -233,39 +233,39 @@ public class WhisparrClient : ArrClient, IWhisparrClient
return JsonConvert.DeserializeObject<Series>(responseContent);
}
private List<WhisparrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
private List<WhisparrV2Command> GetSearchCommands(HashSet<SeriesSearchItem> items)
{
const string episodeSearch = "EpisodeSearch";
const string seasonSearch = "SeasonSearch";
const string seriesSearch = "SeriesSearch";
List<WhisparrCommand> commands = new();
List<WhisparrV2Command> commands = new();
foreach (SeriesSearchItem item in items)
{
WhisparrCommand command = item.SearchType is SeriesSearchType.Episode
WhisparrV2Command v2Command = 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];
case SeriesSearchType.Episode when v2Command.EpisodeIds is null:
v2Command.EpisodeIds = [item.Id];
break;
case SeriesSearchType.Episode when command.EpisodeIds is not null:
command.EpisodeIds.Add(item.Id);
case SeriesSearchType.Episode when v2Command.EpisodeIds is not null:
v2Command.EpisodeIds.Add(item.Id);
break;
case SeriesSearchType.Season:
command.Name = seasonSearch;
command.SeasonNumber = item.Id;
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
v2Command.Name = seasonSearch;
v2Command.SeasonNumber = item.Id;
v2Command.SeriesId = ((SeriesSearchItem)item).SeriesId;
break;
case SeriesSearchType.Series:
command.Name = seriesSearch;
command.SeriesId = item.Id;
v2Command.Name = seriesSearch;
v2Command.SeriesId = item.Id;
break;
default:
@@ -278,8 +278,8 @@ public class WhisparrClient : ArrClient, IWhisparrClient
continue;
}
command.SearchType = item.SearchType;
commands.Add(command);
v2Command.SearchType = item.SearchType;
commands.Add(v2Command);
}
return commands;

View File

@@ -0,0 +1,157 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Domain.Entities.Whisparr;
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 WhisparrV3Client : ArrClient, IWhisparrV3Client
{
public WhisparrV3Client(
ILogger<WhisparrV3Client> logger,
IHttpClientFactory httpClientFactory,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v3/system/status";
}
protected override string GetQueueUrlPath()
{
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
{
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=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;
}
List<long> ids = items.Select(item => item.Id).ToList();
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
WhisparrV3Command command = new()
{
Name = "MoviesSearch",
MovieIds = ids,
};
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.MovieId is 0)
{
_logger.LogDebug("skip | movie id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(Uri instanceUrl, WhisparrV3Command command, bool success, string? logContext)
{
string status = success ? "triggered" : "failed";
string message = logContext ?? $"movie ids: {string.Join(',', command.MovieIds)}";
return $"movie search {status} | {instanceUrl} | {message}";
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, WhisparrV3Command command)
{
try
{
StringBuilder log = new();
foreach (long movieId in command.MovieIds)
{
Movie? movie = await GetMovie(arrInstance, movieId);
if (movie is null)
{
return null;
}
log.Append($"[{movie.Title}]");
}
return log.ToString();
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to compute log context");
}
return null;
}
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Movie>(responseBody);
}
}

View File

@@ -38,7 +38,7 @@ public sealed class DownloadHunter : IDownloadHunter
return;
}
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version);
await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]);
// Prevent manual db edits

View File

@@ -47,7 +47,7 @@ public sealed class QueueItemRemover : IQueueItemRemover
{
try
{
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version);
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
// Set context for EventPublisher
@@ -56,6 +56,7 @@ public sealed class QueueItemRemover : IQueueItemRemover
ContextProvider.Set(nameof(QueueRecord), request.Record);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), request.Instance.Url);
ContextProvider.Set(nameof(InstanceType), request.InstanceType);
ContextProvider.Set("version", request.Instance.Version);
// Use the new centralized EventPublisher method
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);

View File

@@ -96,11 +96,11 @@ public sealed class DownloadCleaner : GenericHandler
// wait for the downloads to appear in the arr queue
await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), InstanceType.Radarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), true);
foreach (var pair in downloadServiceToDownloadsMap)
{
@@ -135,11 +135,11 @@ public sealed class DownloadCleaner : GenericHandler
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
IArrClient arrClient = _arrClientFactory.GetClient(instanceType);
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{

View File

@@ -92,9 +92,9 @@ public abstract class GenericHandler : IHandler
protected abstract Task ExecuteInternalAsync();
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType);
protected abstract Task ProcessInstanceAsync(ArrInstance instance);
protected async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType, bool throwOnFailure = false)
protected async Task ProcessArrConfigAsync(ArrConfig config, bool throwOnFailure = false)
{
var enabledInstances = config.Instances
.Where(x => x.Enabled)
@@ -102,7 +102,7 @@ public abstract class GenericHandler : IHandler
if (enabledInstances.Count is 0)
{
_logger.LogDebug($"Skip processing {instanceType}. No enabled instances found");
_logger.LogDebug($"Skip processing {config.Type}. No enabled instances found");
return;
}
@@ -110,11 +110,11 @@ public abstract class GenericHandler : IHandler
{
try
{
await ProcessInstanceAsync(arrInstance, instanceType);
await ProcessInstanceAsync(arrInstance);
}
catch (Exception exception)
{
_logger.LogError(exception, "failed to process {type} instance | {url}", instanceType, arrInstance.Url);
_logger.LogError(exception, "failed to process {type} instance | {url}", config.Type, arrInstance.Url);
if (throwOnFailure)
{
@@ -140,14 +140,14 @@ public abstract class GenericHandler : IHandler
return;
}
if (instanceType is InstanceType.Sonarr or InstanceType.Whisparr)
if (instanceType is InstanceType.Sonarr || (instanceType is InstanceType.Whisparr && instance.Version is 2))
{
QueueItemRemoveRequest<SeriesSearchItem> removeRequest = new()
{
InstanceType = instanceType,
Instance = instance,
Record = record,
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, record, isPack),
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
};
@@ -161,7 +161,7 @@ public abstract class GenericHandler : IHandler
InstanceType = instanceType,
Instance = instance,
Record = record,
SearchItem = GetRecordSearchItem(instanceType, record, isPack),
SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
};
@@ -173,7 +173,7 @@ public abstract class GenericHandler : IHandler
await _eventPublisher.PublishAsync(EventType.DownloadMarkedForDeletion, "Download marked for deletion", EventSeverity.Important);
}
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record, bool isPack = false)
protected SearchItem GetRecordSearchItem(InstanceType type, float version, QueueRecord record, bool isPack = false)
{
return type switch
{
@@ -201,18 +201,22 @@ public abstract class GenericHandler : IHandler
{
Id = record.BookId
},
InstanceType.Whisparr when !isPack => new SeriesSearchItem
InstanceType.Whisparr when version is 2 && !isPack => new SeriesSearchItem
{
Id = record.EpisodeId,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Episode
},
InstanceType.Whisparr when isPack => new SeriesSearchItem
InstanceType.Whisparr when version is 2 && isPack => new SeriesSearchItem
{
Id = record.SeasonNumber,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Season
},
InstanceType.Whisparr when version is 3 => new SearchItem
{
Id = record.MovieId
},
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -66,42 +66,43 @@ public sealed class MalwareBlocker : GenericHandler
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
await ProcessArrConfigAsync(sonarrConfig);
}
if (config.Radarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
await ProcessArrConfigAsync(radarrConfig);
}
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
await ProcessArrConfigAsync(lidarrConfig);
}
if (config.Readarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
await ProcessArrConfigAsync(readarrConfig);
}
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
await ProcessArrConfigAsync(whisparrConfig);
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
IArrClient arrClient = _arrClientFactory.GetClient(instanceType);
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
// push to context
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), instance.Url);
ContextProvider.Set(nameof(InstanceType), instanceType);
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);
ContextProvider.Set("version", instance.Version);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
@@ -205,7 +206,7 @@ public sealed class MalwareBlocker : GenericHandler
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instanceType,
instance.ArrConfig.Type,
instance,
record,
group.Count() > 1,

View File

@@ -71,27 +71,28 @@ public sealed class QueueCleaner : GenericHandler
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);
await ProcessArrConfigAsync(sonarrConfig);
await ProcessArrConfigAsync(radarrConfig);
await ProcessArrConfigAsync(lidarrConfig);
await ProcessArrConfigAsync(readarrConfig);
await ProcessArrConfigAsync(whisparrConfig);
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
QueueCleanerConfig queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
ignoredDownloads.AddRange(queueCleanerConfig.IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
IArrClient arrClient = _arrClientFactory.GetClient(instanceType);
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
// push to context
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), instance.Url);
ContextProvider.Set(nameof(InstanceType), instanceType);
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);
ContextProvider.Set("version", instance.Version);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
bool hasEnabledTorrentClients = ContextProvider
@@ -183,7 +184,7 @@ public sealed class QueueCleaner : GenericHandler
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instanceType,
instance.ArrConfig.Type,
instance,
record,
group.Count() > 1,
@@ -203,7 +204,7 @@ public sealed class QueueCleaner : GenericHandler
// Failed import check
bool shouldRemoveFromArr = await arrClient
.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
.ShouldRemoveFromQueue(instance.ArrConfig.Type, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
if (shouldRemoveFromArr)
{
@@ -211,7 +212,7 @@ public sealed class QueueCleaner : GenericHandler
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instanceType,
instance.ArrConfig.Type,
instance,
record,
group.Count() > 1,

View File

@@ -120,8 +120,9 @@ public class NotificationPublisher : INotificationPublisher
{
var record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceVersion = (float)ContextProvider.Get<object>("version");
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var imageUrl = GetImageFromContext(record, instanceType);
var imageUrl = GetImageFromContext(record, instanceType, instanceVersion);
NotificationContext context = new()
{
@@ -153,8 +154,9 @@ public class NotificationPublisher : INotificationPublisher
{
var record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceVersion = (float)ContextProvider.Get<object>("version");
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var imageUrl = GetImageFromContext(record, instanceType);
var imageUrl = GetImageFromContext(record, instanceType, instanceVersion);
return new NotificationContext
{
@@ -237,7 +239,7 @@ public class NotificationPublisher : INotificationPublisher
};
}
private Uri? GetImageFromContext(QueueRecord record, InstanceType instanceType)
private Uri? GetImageFromContext(QueueRecord record, InstanceType instanceType, float version)
{
Uri? image = instanceType switch
{
@@ -245,7 +247,8 @@ public class NotificationPublisher : INotificationPublisher
InstanceType.Radarr => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Lidarr => record.Album?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Readarr => record.Book?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Whisparr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Whisparr when version is 2 => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Whisparr when version is 3 => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl ?? record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "screenshot")?.RemoteUrl,
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
};

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -22,7 +22,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -11,9 +11,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.0-rc.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddWhisparrV3 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<float>(
name: "version",
table: "arr_instances",
type: "REAL",
nullable: false,
defaultValue: 0f);
migrationBuilder.Sql(
"""
UPDATE arr_instances
SET version = CASE
WHEN (
SELECT type
FROM arr_configs
WHERE arr_configs.id = arr_instances.arr_config_id
) = 'sonarr' THEN 4
WHEN (
SELECT type
FROM arr_configs
WHERE arr_configs.id = arr_instances.arr_config_id
) = 'radarr' THEN 6
WHEN (
SELECT type
FROM arr_configs
WHERE arr_configs.id = arr_instances.arr_config_id
) = 'lidarr' THEN 3
WHEN (
SELECT type
FROM arr_configs
WHERE arr_configs.id = arr_instances.arr_config_id
) = 'readarr' THEN 0.4
WHEN (
SELECT type
FROM arr_configs
WHERE arr_configs.id = arr_instances.arr_config_id
) = 'whisparr' THEN 2
END;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "version",
table: "arr_instances");
}
}
}

View File

@@ -70,6 +70,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("url");
b.Property<float>("Version")
.HasColumnType("REAL")
.HasColumnName("version");
b.HasKey("Id")
.HasName("pk_arr_instances");

View File

@@ -12,6 +12,8 @@ public sealed class ArrInstance
public bool Enabled { get; set; }
public float Version { get; set; }
public Guid ArrConfigId { get; set; }
public ArrConfig ArrConfig { get; set; } = null!;

View File

@@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -112,7 +112,6 @@
"cli": {
"schematicCollections": [
"angular-eslint"
],
"analytics": false
]
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,14 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.17",
"@angular/cdk": "^19.2.17",
"@angular/common": "^19.2.17",
"@angular/compiler": "^19.2.17",
"@angular/core": "^19.2.17",
"@angular/forms": "^19.2.17",
"@angular/platform-browser": "^19.2.17",
"@angular/platform-browser-dynamic": "^19.2.17",
"@angular/router": "^19.2.17",
"@angular/service-worker": "^19.2.17",
"@angular/common": "^19.2.16",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.16",
"@angular/forms": "^19.2.16",
"@angular/platform-browser": "^19.2.16",
"@angular/platform-browser-dynamic": "^19.2.16",
"@angular/router": "^19.2.16",
"@angular/service-worker": "^19.2.16",
"@microsoft/signalr": "^8.0.7",
"@ngrx/signals": "^19.2.0",
"@primeng/themes": "^19.1.3",
@@ -32,9 +30,9 @@
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.17",
"@angular/cli": "^19.2.17",
"@angular/compiler-cli": "^19.2.17",
"@angular-devkit/build-angular": "^19.2.12",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "^19.2.16",
"@types/jasmine": "~5.1.0",
"angular-eslint": "19.6.0",
"eslint": "^9.27.0",

View File

@@ -216,12 +216,15 @@
</div>
<span class="text-xs text-color-secondary">{{event.timestamp | date: 'yyyy-MM-dd HH:mm:ss'}}</span>
</div>
<div class="timeline-message"
[pTooltip]="event.message"
<div class="timeline-message"
[pTooltip]="event.message"
tooltipPosition="top"
[showDelay]="500">
{{truncateMessage(event.message)}}
</div>
<div class="text-xs text-color-secondary mt-1" *ngIf="getDownloadName(event)">
{{getDownloadName(event)}}
</div>
<div class="text-xs text-color-secondary mt-1" *ngIf="event.trackingId">
Tracking: {{event.trackingId}}
</div>

View File

@@ -361,6 +361,12 @@ export class DashboardPageComponent implements OnInit, OnDestroy {
}
}
// Extract downloadName from event data if available
getDownloadName(event: AppEvent): string | null {
const parsed = this.parseEventData(event.data);
return parsed?.downloadName || null;
}
// Process message to convert URLs to clickable links and handle newlines
processManualEventMessage(message: string): string {
if (!message) return '';

View File

@@ -213,16 +213,30 @@
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Lidarr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
</div>
<div class="field">
<label for="instance-version">Version *</label>
<p-select
id="instance-version"
formControlName="version"
[options]="versionOptions"
optionLabel="label"
optionValue="value"
placeholder="Select version"
styleClass="w-full"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>
</form>
<ng-template pTemplate="footer">

View File

@@ -17,6 +17,7 @@ import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { TagModule } from "primeng/tag";
import { SelectModule } from "primeng/select";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@@ -37,6 +38,7 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [LidarrConfigStore, ConfirmationService],
@@ -51,6 +53,10 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
globalForm: FormGroup;
instanceForm: FormGroup;
versionOptions = [
{ label: 'v3', value: 3 }
];
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
@@ -97,6 +103,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: ['', Validators.required],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
version: [3, Validators.required],
});
// Load Lidarr config data
@@ -316,7 +323,8 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
enabled: true,
name: '',
url: '',
apiKey: ''
apiKey: '',
version: 3
});
this.showInstanceModal = true;
}
@@ -332,6 +340,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -361,6 +370,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: this.instanceForm.get('name')?.value,
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
if (this.modalMode === 'add') {
@@ -452,6 +462,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
this.lidarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
@@ -464,6 +475,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
};
this.lidarrStore.testInstance({ request: testRequest, instanceId: instance.id });

View File

@@ -213,16 +213,30 @@
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Radarr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
</div>
<div class="field">
<label for="instance-version">Version *</label>
<p-select
id="instance-version"
formControlName="version"
[options]="versionOptions"
optionLabel="label"
optionValue="value"
placeholder="Select version"
styleClass="w-full"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>
</form>
<ng-template pTemplate="footer">

View File

@@ -17,6 +17,7 @@ import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { TagModule } from "primeng/tag";
import { SelectModule } from "primeng/select";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@@ -37,6 +38,7 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [RadarrConfigStore, ConfirmationService],
@@ -51,6 +53,11 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
globalForm: FormGroup;
instanceForm: FormGroup;
// Version options for Radarr (v6 only)
versionOptions = [
{ label: 'v6', value: 6 }
];
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
@@ -97,6 +104,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: ['', Validators.required],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
version: [6, Validators.required],
});
// Load Radarr config data
@@ -316,7 +324,8 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
enabled: true,
name: '',
url: '',
apiKey: ''
apiKey: '',
version: 6
});
this.showInstanceModal = true;
}
@@ -332,6 +341,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -361,6 +371,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: this.instanceForm.get('name')?.value,
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
if (this.modalMode === 'add') {
@@ -452,6 +463,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
this.radarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
@@ -464,6 +476,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
};
this.radarrStore.testInstance({ request: testRequest, instanceId: instance.id });

View File

@@ -213,16 +213,30 @@
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Readarr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
</div>
<div class="field">
<label for="instance-version">Version *</label>
<p-select
id="instance-version"
formControlName="version"
[options]="versionOptions"
optionLabel="label"
optionValue="value"
placeholder="Select version"
styleClass="w-full"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>
</form>
<ng-template pTemplate="footer">

View File

@@ -17,6 +17,7 @@ import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { TagModule } from "primeng/tag";
import { SelectModule } from "primeng/select";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@@ -37,6 +38,7 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [ReadarrConfigStore, ConfirmationService],
@@ -51,6 +53,10 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
globalForm: FormGroup;
instanceForm: FormGroup;
versionOptions = [
{ label: 'v0.4', value: 0.4 }
];
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
@@ -97,6 +103,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
name: ['', Validators.required],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
version: [0.4, Validators.required],
});
// Load Readarr config data
@@ -303,7 +310,8 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
enabled: true,
name: '',
url: '',
apiKey: ''
apiKey: '',
version: 0.4
});
this.showInstanceModal = true;
}
@@ -319,6 +327,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -348,6 +357,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
name: this.instanceForm.get('name')?.value,
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
if (this.modalMode === 'add') {
@@ -452,6 +462,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
this.readarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
@@ -464,6 +475,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
};
this.readarrStore.testInstance({ request: testRequest, instanceId: instance.id });

View File

@@ -213,16 +213,30 @@
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Sonarr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
</div>
<div class="field">
<label for="instance-version">Version *</label>
<p-select
id="instance-version"
formControlName="version"
[options]="versionOptions"
optionLabel="label"
optionValue="value"
placeholder="Select version"
styleClass="w-full"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>
</form>
<ng-template pTemplate="footer">

View File

@@ -17,6 +17,7 @@ import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { TagModule } from "primeng/tag";
import { SelectModule } from "primeng/select";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@@ -37,6 +38,7 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [SonarrConfigStore, ConfirmationService],
@@ -51,6 +53,10 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
globalForm: FormGroup;
instanceForm: FormGroup;
versionOptions = [
{ label: 'v4', value: 4 }
];
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
@@ -97,6 +103,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: ['', Validators.required],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
version: [4, Validators.required],
});
// Load Sonarr config data
@@ -316,7 +323,8 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
enabled: true,
name: '',
url: '',
apiKey: ''
apiKey: '',
version: 4
});
this.showInstanceModal = true;
}
@@ -332,6 +340,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -361,6 +370,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: this.instanceForm.get('name')?.value,
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
if (this.modalMode === 'add') {
@@ -452,6 +462,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
this.sonarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
@@ -464,6 +475,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
};
this.sonarrStore.testInstance({ request: testRequest, instanceId: instance.id });

View File

@@ -213,16 +213,30 @@
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Whisparr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
</div>
<div class="field">
<label for="instance-version">Version *</label>
<p-select
id="instance-version"
formControlName="version"
[options]="versionOptions"
optionLabel="label"
optionValue="value"
placeholder="Select version"
styleClass="w-full"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>
</form>
<ng-template pTemplate="footer">

View File

@@ -17,6 +17,7 @@ import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { TagModule } from "primeng/tag";
import { SelectModule } from "primeng/select";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@@ -37,6 +38,7 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [WhisparrConfigStore, ConfirmationService],
@@ -51,6 +53,11 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
globalForm: FormGroup;
instanceForm: FormGroup;
versionOptions = [
{ label: 'v2', value: 2 },
{ label: 'v3', value: 3 }
];
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
@@ -97,6 +104,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
name: ['', Validators.required],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
version: [3, Validators.required],
});
// Load Whisparr config data
@@ -311,7 +319,8 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
enabled: true,
name: '',
url: '',
apiKey: ''
apiKey: '',
version: 3
});
this.showInstanceModal = true;
}
@@ -327,6 +336,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -356,6 +366,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
name: this.instanceForm.get('name')?.value,
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
if (this.modalMode === 'add') {
@@ -447,6 +458,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
version: this.instanceForm.get('version')?.value,
};
this.whisparrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
@@ -459,6 +471,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
};
this.whisparrStore.testInstance({ request: testRequest, instanceId: instance.id });

View File

@@ -7,6 +7,7 @@ export interface ArrInstance {
name: string;
url: string;
apiKey: string;
version: number;
}
/**
@@ -17,6 +18,7 @@ export interface CreateArrInstanceDto {
name: string;
url: string;
apiKey: string;
version: number;
}
/**
@@ -25,4 +27,5 @@ export interface CreateArrInstanceDto {
export interface TestArrInstanceRequest {
url: string;
apiKey: string;
version: number;
}

View File

@@ -2,6 +2,7 @@
sidebar_position: 3
---
import { Important } from '@site/src/components/documentation';
import {
AppCard,
styles
@@ -10,7 +11,11 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
# Supported Apps
Cleanuparr integrates with popular *arr applications and download clients for media management automation.
Cleanuparr integrates with the servarr applications and download clients for media management automation.
<Important>
Only the latest versions of the following applications are supported, unless explicitly specified otherwise.
</Important>
<div className={styles.documentationPage}>

View File

@@ -96,6 +96,7 @@ services:
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | 11011 | Port for the web interface |
| `BIND_ADDRESS` | 0.0.0.0 | IP address to bind to |
| `BASE_PATH` | *(empty)* | Base path for reverse proxy setups ([examples](#example-configurations)) |
| `PUID` | 1000 | User ID for file permissions |
| `PGID` | 1000 | Group ID for file permissions |
@@ -277,6 +278,7 @@ To run Cleanuparr as a systemd service:
Restart=always
RestartSec=5
Environment=PORT=11011
Environment=BIND_ADDRESS=0.0.0.0
Environment=BASE_PATH=
[Install]
@@ -330,7 +332,7 @@ To change the port or base path for AUR installations, see [Port and Base Path (
<SectionTitle>Port and Base Path (Non-Docker)</SectionTitle>
For all non-Docker installations (Windows, macOS, Linux portable), you can configure the PORT and BASE_PATH by creating a configuration file.
For all non-Docker installations (Windows, macOS, Linux portable), you can configure the PORT, BIND_ADDRESS and BASE_PATH by creating a configuration file.
### Initial Setup
@@ -347,6 +349,7 @@ For all non-Docker installations (Windows, macOS, Linux portable), you can confi
```json
{
"PORT": 11011,
"BIND_ADDRESS": "0.0.0.0",
"BASE_PATH": ""
}
```
@@ -358,11 +361,19 @@ For all non-Docker installations (Windows, macOS, Linux portable), you can confi
**Running on port 8080:**
```json
{
"PORT": 8080,
"PORT": 8080
}
```
Access via: `http://localhost:8080`
**Binding to localhost only:**
```json
{
"BIND_ADDRESS": "127.0.0.1"
}
```
Only accessible from the local machine.
**Running with reverse proxy at `/cleanuparr`:**
```json
{

View File

@@ -39,16 +39,16 @@ Download and configure the .NET SDK for FreeBSD:
cd ~
# Set up variables for cleaner commands
DOTNET_VERSION="v10.0.101-amd64-freebsd-14"
DOTNET_VERSION="v9.0.104-amd64-freebsd-14"
DOTNET_BASE_URL="https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download"
# Download .NET SDK
wget -q "${DOTNET_BASE_URL}/${DOTNET_VERSION}/dotnet-sdk-10.0.101-freebsd-x64.tar.gz"
wget -q "${DOTNET_BASE_URL}/${DOTNET_VERSION}/dotnet-sdk-9.0.104-freebsd-x64.tar.gz"
# Set up .NET environment
export DOTNET_ROOT=$(pwd)/.dotnet
mkdir -p "$DOTNET_ROOT"
tar zxf dotnet-sdk-10.0.101-freebsd-x64.tar.gz -C "$DOTNET_ROOT"
tar zxf dotnet-sdk-9.0.104-freebsd-x64.tar.gz -C "$DOTNET_ROOT"
export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools
```
</Step>
@@ -70,7 +70,7 @@ mkdir -p /tmp/nuget
# Set up variables for package URLs
NUGET_BASE_URL="${DOTNET_BASE_URL}/${DOTNET_VERSION}"
RUNTIME_VERSION="10.0.1"
RUNTIME_VERSION="9.0.3"
# Download required packages
wget -q -P /tmp/nuget/ \

37
docs/package-lock.json generated
View File

@@ -220,7 +220,6 @@
"version": "5.41.0",
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.41.0.tgz",
"integrity": "sha512-G9I2atg1ShtFp0t7zwleP6aPS4DcZvsV4uoQOripp16aR6VJzbEnKFPLW4OFXzX7avgZSpYeBAS+Zx4FOgmpPw==",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.41.0",
"@algolia/requester-browser-xhr": "5.41.0",
@@ -336,7 +335,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2017,7 +2015,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2039,7 +2036,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2144,7 +2140,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2550,7 +2545,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -3395,7 +3389,6 @@
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz",
"integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==",
"peer": true,
"dependencies": {
"@docusaurus/core": "3.9.2",
"@docusaurus/logger": "3.9.2",
@@ -4112,7 +4105,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
"integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==",
"peer": true,
"dependencies": {
"@types/mdx": "^2.0.0"
},
@@ -4405,7 +4397,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -4739,7 +4730,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -5052,7 +5042,6 @@
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5120,7 +5109,6 @@
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -5163,7 +5151,6 @@
"version": "5.41.0",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.41.0.tgz",
"integrity": "sha512-9E4b3rJmYbBkn7e3aAPt1as+VVnRhsR4qwRRgOzpeyz4PAOuwKh0HI4AN6mTrqK0S0M9fCCSTOUnuJ8gPY/tvA==",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.7.0",
"@algolia/client-abtesting": "5.41.0",
@@ -5704,7 +5691,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6627,7 +6613,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -7930,7 +7915,6 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -10047,10 +10031,9 @@
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
@@ -12147,7 +12130,6 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -12628,7 +12610,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13484,7 +13465,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -14281,7 +14261,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14290,7 +14269,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -14341,7 +14319,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
"peer": true,
"dependencies": {
"@types/react": "*"
},
@@ -14394,7 +14371,6 @@
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@@ -16165,8 +16141,7 @@
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"peer": true
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
@@ -16234,7 +16209,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"devOptional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16552,7 +16526,6 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -16740,7 +16713,6 @@
"version": "5.99.5",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.5.tgz",
"integrity": "sha512-q+vHBa6H9qwBLUlHL4Y7L0L1/LlyBKZtS9FHNCQmtayxjI5RKC9yD8gpvLeqGv5lCQp1Re04yi0MF40pf30Pvg==",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@@ -17315,7 +17287,6 @@
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -4668,11 +4668,6 @@ fs-extra@^11.1.1, fs-extra@^11.2.0:
jsonfile "^6.0.1"
universalify "^2.0.0"
fsevents@~2.3.2:
version "2.3.3"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -5901,9 +5896,9 @@ mdast-util-phrasing@^4.0.0:
unist-util-is "^6.0.0"
mdast-util-to-hast@^13.0.0:
version "13.2.1"
resolved "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz"
integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==
version "13.2.0"
resolved "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz"
integrity sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==
dependencies:
"@types/hast" "^3.0.0"
"@types/mdast" "^4.0.0"