Compare commits

..

3 Commits

Author SHA1 Message Date
Flaminel
605c109de2 updated backend packages and dotnet version 2025-12-30 00:06:54 +02:00
Flaminel
399a363090 updated frontend packages 2025-12-29 23:17:14 +02:00
Flaminel
2270a0c6bf updated docs packages 2025-12-29 23:07:22 +02:00
76 changed files with 729 additions and 2787 deletions

View File

@@ -76,7 +76,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.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: 9.0.x
dotnet-version: 10.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: 9.0.x
dotnet-version: 10.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: 9.0.x
dotnet-version: 10.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 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.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,23 +28,20 @@ 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 (latest version)
### *Arr Applications
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr v2**
- **Whisparr**
### Download Clients (latest version)
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
@@ -119,4 +116,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:9.0-bookworm-slim AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 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:9.0-bookworm-slim
FROM mcr.microsoft.com/dotnet/aspnet:10.0
# 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>net9.0</TargetFramework>
<TargetFramework>net10.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="9.0.6">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<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="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="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,6 +1,8 @@
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;
@@ -174,7 +176,7 @@ public class StatusController : ControllerBase
{
try
{
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
@@ -206,7 +208,7 @@ public class StatusController : ControllerBase
{
try
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
@@ -238,7 +240,7 @@ public class StatusController : ControllerBase
{
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new

View File

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

View File

@@ -18,9 +18,6 @@ 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,
@@ -28,7 +25,6 @@ public sealed record ArrInstanceRequest
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = configId,
Version = Version,
};
public void ApplyTo(ArrInstance instance)
@@ -37,6 +33,5 @@ public sealed record ArrInstanceRequest
instance.Name = Name;
instance.Url = new Uri(Url);
instance.ApiKey = ApiKey;
instance.Version = Version;
}
}

View File

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

View File

@@ -1,11 +1,17 @@
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;
@@ -283,7 +289,7 @@ public sealed class ArrConfigController : ControllerBase
try
{
var testInstance = request.ToTestInstance();
var client = _arrClientFactory.GetClient(type, request.Version);
var client = _arrClientFactory.GetClient(type);
await client.HealthCheckAsync(testInstance);
return Ok(new { Message = $"Connection to {type} instance successful" });

View File

@@ -1,4 +1,3 @@
using System.Net;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Cleanuparr.Api;
@@ -34,24 +33,12 @@ 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 =>
{
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}'");
}
options.ListenAnyIP(port);
});
}
@@ -137,7 +124,7 @@ if (basePath is not null)
}
}
logger.LogInformation("Server configuration: BIND_ADDRESS={bindAddress}, PORT={port}, BASE_PATH={basePath}", bindAddress ?? "0.0.0.0", port, basePath ?? "/");
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
// Initialize the host
app.Init();

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.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 v2
// Sonarr and Whisparr
public long SeriesId { get; init; }
public long EpisodeId { get; init; }
public long SeasonNumber { get; init; }
public QueueSeries? Series { get; init; }
// Radarr and Whisparr v3
// Radarr
public long MovieId { get; init; }
public QueueMovie? Movie { get; init; }
public QueueSeries? 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 WhisparrV2Command
public sealed record WhisparrCommand
{
public string Name { get; set; }

View File

@@ -1,8 +0,0 @@
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>net9.0</TargetFramework>
<TargetFramework>net10.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="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.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.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.0.1">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

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

@@ -1,178 +0,0 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
public class WhisparrV2ClientTests
{
private readonly Mock<ILogger<WhisparrV2Client>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<IStriker> _strikerMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly WhisparrV2Client _client;
public WhisparrV2ClientTests()
{
_loggerMock = new Mock<ILogger<WhisparrV2Client>>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_strikerMock = new Mock<IStriker>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
_client = new WhisparrV2Client(
_loggerMock.Object,
_httpClientFactoryMock.Object,
_strikerMock.Object,
_dryRunInterceptorMock.Object
);
}
#region IsRecordValid Tests
[Fact]
public void IsRecordValid_WhenEpisodeIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 0,
SeriesId = 1
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("episode id and/or series id missing")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public void IsRecordValid_WhenSeriesIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 1,
SeriesId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenBothIdsAreZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 0,
SeriesId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenBothIdsAreSet_ReturnsTrue()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.True(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = null!,
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "",
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
#endregion
}

View File

@@ -1,132 +0,0 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
public class WhisparrV3ClientTests
{
private readonly Mock<ILogger<WhisparrV3Client>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<IStriker> _strikerMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly WhisparrV3Client _client;
public WhisparrV3ClientTests()
{
_loggerMock = new Mock<ILogger<WhisparrV3Client>>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_strikerMock = new Mock<IStriker>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
_client = new WhisparrV3Client(
_loggerMock.Object,
_httpClientFactoryMock.Object,
_strikerMock.Object,
_dryRunInterceptorMock.Object
);
}
#region IsRecordValid Tests
[Fact]
public void IsRecordValid_WhenMovieIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "abc123",
Protocol = "torrent",
MovieId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("movie id missing")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public void IsRecordValid_WhenMovieIdIsSet_ReturnsTrue()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "abc123",
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.True(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = null!,
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "",
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
#endregion
}

View File

@@ -42,7 +42,7 @@ public class DownloadHunterTests : IDisposable
_fakeTimeProvider = new FakeTimeProvider();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
.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>(), It.IsAny<float>()), Times.Never);
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>()), 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, It.IsAny<float>()), Times.Once);
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType), Times.Once);
}
[Fact]
@@ -148,7 +148,7 @@ public class DownloadHunterTests : IDisposable
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
}
#endregion
@@ -292,8 +292,7 @@ public class DownloadHunterTests : IDisposable
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key",
Version = 0
ApiKey = "test-api-key"
};
}

View File

@@ -44,7 +44,7 @@ public class QueueItemRemoverTests : IDisposable
_arrClientMock = new Mock<IArrClient>();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
.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, It.IsAny<float>()), Times.Once);
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), 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>(), It.IsAny<float>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.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>(), It.IsAny<float>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.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, It.IsAny<float>()),
x => x.GetClient(InstanceType.Sonarr),
Times.Once
);
_fixture.ArrClientFactory.Verify(
x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()),
x => x.GetClient(InstanceType.Radarr),
Times.Once
);
}
@@ -502,7 +502,7 @@ public class DownloadCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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>(), It.IsAny<float>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), 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>(), It.IsAny<float>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.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, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), 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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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>(), It.IsAny<float>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.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>(), It.IsAny<float>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.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>(), It.IsAny<float>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.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, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), 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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Sonarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Radarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Radarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Lidarr))
.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, It.IsAny<float>()))
.Setup(x => x.GetClient(InstanceType.Readarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -1040,240 +1040,5 @@ public class QueueCleanerTests : IDisposable
);
}
[Fact]
public async Task PublishQueueItemRemoveRequest_ForWhisparrV2_PublishesSeriesSearchItemRequest()
{
// Arrange - test that Whisparr v2 uses SeriesSearchItem
var whisparrInstance = TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext, version: 2);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Whisparr, 2f))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "whisparr-v2-download-id",
Title = "Whisparr V2 Download",
Protocol = "torrent",
SeriesId = 10,
EpisodeId = 100
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.ShouldRemoveFromArrQueueAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new DownloadCheckResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteFromClient = true,
DeleteReason = DeleteReason.Stalled
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - should publish QueueItemRemoveRequest<SeriesSearchItem>
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.InstanceType == InstanceType.Whisparr &&
r.SearchItem.Id == 100 && // EpisodeId
r.SearchItem.SeriesId == 10 &&
r.SearchItem.SearchType == SeriesSearchType.Episode &&
r.DeleteReason == DeleteReason.Stalled
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
[Fact]
public async Task PublishQueueItemRemoveRequest_ForWhisparrV3_PublishesSearchItemRequest()
{
// Arrange - test that Whisparr v3 uses SearchItem
var whisparrInstance = TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext, version: 3);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Whisparr, 3f))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "whisparr-v3-download-id",
Title = "Whisparr V3 Download",
Protocol = "torrent",
MovieId = 42
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.ShouldRemoveFromArrQueueAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new DownloadCheckResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteFromClient = true,
DeleteReason = DeleteReason.Stalled
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - should publish QueueItemRemoveRequest<SearchItem> with MovieId
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
r.InstanceType == InstanceType.Whisparr &&
r.SearchItem.Id == 42 && // MovieId
r.DeleteReason == DeleteReason.Stalled
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
[Fact]
public async Task PublishQueueItemRemoveRequest_ForWhisparrV2Pack_PublishesSeasonSearchItemRequest()
{
// Arrange - test that Whisparr v2 pack (multiple records with same download ID) uses SeriesSearchItem with Season search type
var whisparrInstance = TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext, version: 2);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Whisparr, 2f))
.Returns(mockArrClient.Object);
// Create multiple records with same download ID to simulate a pack (season pack)
var record1 = new QueueRecord
{
Id = 1,
DownloadId = "whisparr-v2-pack-download-id",
Title = "Whisparr V2 Season Pack - Episode 1",
Protocol = "torrent",
SeriesId = 10,
EpisodeId = 100,
SeasonNumber = 3
};
var record2 = new QueueRecord
{
Id = 2,
DownloadId = "whisparr-v2-pack-download-id",
Title = "Whisparr V2 Season Pack - Episode 2",
Protocol = "torrent",
SeriesId = 10,
EpisodeId = 101,
SeasonNumber = 3
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([record1, record2]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.ShouldRemoveFromArrQueueAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new DownloadCheckResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteFromClient = true,
DeleteReason = DeleteReason.Stalled
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - should publish QueueItemRemoveRequest<SeriesSearchItem> with Season search type
// because multiple records with the same download ID indicate a pack
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.InstanceType == InstanceType.Whisparr &&
r.SearchItem.Id == 3 && // SeasonNumber
r.SearchItem.SeriesId == 10 &&
r.SearchItem.SearchType == SeriesSearchType.Season &&
r.DeleteReason == DeleteReason.Stalled
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
#endregion
}

View File

@@ -197,17 +197,16 @@ public static class TestDataContextFactory
/// <summary>
/// Adds an enabled Whisparr instance to the context
/// </summary>
public static ArrInstance AddWhisparrInstance(DataContext context, string url = "http://whisparr:6969", bool enabled = true, float version = 2)
public static ArrInstance AddWhisparrInstance(DataContext context, string url = "http://whisparr:6969", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Whisparr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = $"Test Whisparr v{version}",
Name = "Test Whisparr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
Version = version,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};

View File

@@ -58,9 +58,8 @@ public class NotificationPublisherTests
};
ContextProvider.Set(nameof(QueueRecord), record);
ContextProvider.Set(nameof(InstanceType), instanceType);
ContextProvider.Set(nameof(InstanceType), (object)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>net9.0</TargetFramework>
<TargetFramework>net10.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.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="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="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.0" />
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.1" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,9 @@
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

@@ -2,6 +2,14 @@ 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>
@@ -14,8 +22,6 @@ public record ArrInstanceDto
public bool Enabled { get; init; } = true;
public float Version { get; set; }
[Required]
public required string Name { get; init; }

View File

@@ -0,0 +1,9 @@
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, float instanceVersion);
IArrClient GetClient(InstanceType type);
}

View File

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

View File

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

View File

@@ -1,157 +0,0 @@
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, request.Instance.Version);
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
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, request.Instance.Version);
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
// Set context for EventPublisher
@@ -56,7 +56,6 @@ 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)), 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);
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);
foreach (var pair in downloadServiceToDownloadsMap)
{
@@ -135,11 +135,11 @@ public sealed class DownloadCleaner : GenericHandler
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance)
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
IArrClient arrClient = _arrClientFactory.GetClient(instanceType);
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);
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType);
protected async Task ProcessArrConfigAsync(ArrConfig config, bool throwOnFailure = false)
protected async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType, 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 {config.Type}. No enabled instances found");
_logger.LogDebug($"Skip processing {instanceType}. No enabled instances found");
return;
}
@@ -110,11 +110,11 @@ public abstract class GenericHandler : IHandler
{
try
{
await ProcessInstanceAsync(arrInstance);
await ProcessInstanceAsync(arrInstance, instanceType);
}
catch (Exception exception)
{
_logger.LogError(exception, "failed to process {type} instance | {url}", config.Type, arrInstance.Url);
_logger.LogError(exception, "failed to process {type} instance | {url}", instanceType, arrInstance.Url);
if (throwOnFailure)
{
@@ -140,14 +140,14 @@ public abstract class GenericHandler : IHandler
return;
}
if (instanceType is InstanceType.Sonarr || (instanceType is InstanceType.Whisparr && instance.Version is 2))
if (instanceType is InstanceType.Sonarr or InstanceType.Whisparr)
{
QueueItemRemoveRequest<SeriesSearchItem> removeRequest = new()
{
InstanceType = instanceType,
Instance = instance,
Record = record,
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack),
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
};
@@ -161,7 +161,7 @@ public abstract class GenericHandler : IHandler
InstanceType = instanceType,
Instance = instance,
Record = record,
SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack),
SearchItem = GetRecordSearchItem(instanceType, 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, float version, QueueRecord record, bool isPack = false)
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record, bool isPack = false)
{
return type switch
{
@@ -201,22 +201,18 @@ public abstract class GenericHandler : IHandler
{
Id = record.BookId
},
InstanceType.Whisparr when version is 2 && !isPack => new SeriesSearchItem
InstanceType.Whisparr when !isPack => new SeriesSearchItem
{
Id = record.EpisodeId,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Episode
},
InstanceType.Whisparr when version is 2 && isPack => new SeriesSearchItem
InstanceType.Whisparr when 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,43 +66,42 @@ public sealed class MalwareBlocker : GenericHandler
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(sonarrConfig);
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
}
if (config.Radarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(radarrConfig);
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
}
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(lidarrConfig);
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(readarrConfig);
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(whisparrConfig);
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance)
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
IArrClient arrClient = _arrClientFactory.GetClient(instanceType);
// push to context
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), instance.Url);
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);
ContextProvider.Set("version", instance.Version);
ContextProvider.Set(nameof(InstanceType), instanceType);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
@@ -206,7 +205,7 @@ public sealed class MalwareBlocker : GenericHandler
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instance.ArrConfig.Type,
instanceType,
instance,
record,
group.Count() > 1,

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.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.0.1">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<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>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -11,9 +11,9 @@
</ItemGroup>
<ItemGroup>
<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="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="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

@@ -1,61 +0,0 @@
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,10 +70,6 @@ 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,8 +12,6 @@ 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>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
</ItemGroup>
</Project>

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -216,15 +216,12 @@
</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,12 +361,6 @@ 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,30 +213,16 @@
<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,7 +17,6 @@ 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";
@@ -38,7 +37,6 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [LidarrConfigStore, ConfirmationService],
@@ -53,10 +51,6 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
globalForm: FormGroup;
instanceForm: FormGroup;
versionOptions = [
{ label: 'v3', value: 3 }
];
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
@@ -103,7 +97,6 @@ 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
@@ -323,8 +316,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
enabled: true,
name: '',
url: '',
apiKey: '',
version: 3
apiKey: ''
});
this.showInstanceModal = true;
}
@@ -340,7 +332,6 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -370,7 +361,6 @@ 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') {
@@ -462,7 +452,6 @@ 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 });
@@ -475,7 +464,6 @@ 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,30 +213,16 @@
<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,7 +17,6 @@ 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";
@@ -38,7 +37,6 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [RadarrConfigStore, ConfirmationService],
@@ -53,11 +51,6 @@ 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';
@@ -104,7 +97,6 @@ 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
@@ -324,8 +316,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
enabled: true,
name: '',
url: '',
apiKey: '',
version: 6
apiKey: ''
});
this.showInstanceModal = true;
}
@@ -341,7 +332,6 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -371,7 +361,6 @@ 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') {
@@ -463,7 +452,6 @@ 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 });
@@ -476,7 +464,6 @@ 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,30 +213,16 @@
<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,7 +17,6 @@ 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";
@@ -38,7 +37,6 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [ReadarrConfigStore, ConfirmationService],
@@ -53,10 +51,6 @@ 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';
@@ -103,7 +97,6 @@ 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
@@ -310,8 +303,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
enabled: true,
name: '',
url: '',
apiKey: '',
version: 0.4
apiKey: ''
});
this.showInstanceModal = true;
}
@@ -327,7 +319,6 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -357,7 +348,6 @@ 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') {
@@ -462,7 +452,6 @@ 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 });
@@ -475,7 +464,6 @@ 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,30 +213,16 @@
<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,7 +17,6 @@ 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";
@@ -38,7 +37,6 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [SonarrConfigStore, ConfirmationService],
@@ -53,10 +51,6 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
globalForm: FormGroup;
instanceForm: FormGroup;
versionOptions = [
{ label: 'v4', value: 4 }
];
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
@@ -103,7 +97,6 @@ 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
@@ -323,8 +316,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
enabled: true,
name: '',
url: '',
apiKey: '',
version: 4
apiKey: ''
});
this.showInstanceModal = true;
}
@@ -340,7 +332,6 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -370,7 +361,6 @@ 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') {
@@ -462,7 +452,6 @@ 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 });
@@ -475,7 +464,6 @@ 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,30 +213,16 @@
<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,7 +17,6 @@ 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";
@@ -38,7 +37,6 @@ import { UrlValidators } from "../../core/validators/url.validator";
DialogModule,
ConfirmDialogModule,
TagModule,
SelectModule,
LoadingErrorStateComponent,
],
providers: [WhisparrConfigStore, ConfirmationService],
@@ -53,11 +51,6 @@ 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';
@@ -104,7 +97,6 @@ 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
@@ -319,8 +311,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
enabled: true,
name: '',
url: '',
apiKey: '',
version: 3
apiKey: ''
});
this.showInstanceModal = true;
}
@@ -336,7 +327,6 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
version: instance.version,
});
this.showInstanceModal = true;
}
@@ -366,7 +356,6 @@ 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') {
@@ -458,7 +447,6 @@ 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 });
@@ -471,7 +459,6 @@ 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,7 +7,6 @@ export interface ArrInstance {
name: string;
url: string;
apiKey: string;
version: number;
}
/**
@@ -18,7 +17,6 @@ export interface CreateArrInstanceDto {
name: string;
url: string;
apiKey: string;
version: number;
}
/**
@@ -27,5 +25,4 @@ export interface CreateArrInstanceDto {
export interface TestArrInstanceRequest {
url: string;
apiKey: string;
version: number;
}

View File

@@ -2,7 +2,6 @@
sidebar_position: 3
---
import { Important } from '@site/src/components/documentation';
import {
AppCard,
styles
@@ -11,11 +10,7 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
# Supported Apps
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>
Cleanuparr integrates with popular *arr applications and download clients for media management automation.
<div className={styles.documentationPage}>

View File

@@ -96,7 +96,6 @@ 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 |
@@ -278,7 +277,6 @@ 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]
@@ -332,7 +330,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, BIND_ADDRESS and BASE_PATH by creating a configuration file.
For all non-Docker installations (Windows, macOS, Linux portable), you can configure the PORT and BASE_PATH by creating a configuration file.
### Initial Setup
@@ -349,7 +347,6 @@ For all non-Docker installations (Windows, macOS, Linux portable), you can confi
```json
{
"PORT": 11011,
"BIND_ADDRESS": "0.0.0.0",
"BASE_PATH": ""
}
```
@@ -361,19 +358,11 @@ 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="v9.0.104-amd64-freebsd-14"
DOTNET_VERSION="v10.0.101-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-9.0.104-freebsd-x64.tar.gz"
wget -q "${DOTNET_BASE_URL}/${DOTNET_VERSION}/dotnet-sdk-10.0.101-freebsd-x64.tar.gz"
# Set up .NET environment
export DOTNET_ROOT=$(pwd)/.dotnet
mkdir -p "$DOTNET_ROOT"
tar zxf dotnet-sdk-9.0.104-freebsd-x64.tar.gz -C "$DOTNET_ROOT"
tar zxf dotnet-sdk-10.0.101-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="9.0.3"
RUNTIME_VERSION="10.0.1"
# Download required packages
wget -q -P /tmp/nuget/ \

37
docs/package-lock.json generated
View File

@@ -220,6 +220,7 @@
"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",
@@ -335,6 +336,7 @@
"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",
@@ -2015,6 +2017,7 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2036,6 +2039,7 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2140,6 +2144,7 @@
"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"
@@ -2545,6 +2550,7 @@
"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"
@@ -3389,6 +3395,7 @@
"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",
@@ -4105,6 +4112,7 @@
"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"
},
@@ -4397,6 +4405,7 @@
"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",
@@ -4730,6 +4739,7 @@
"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"
}
@@ -5042,6 +5052,7 @@
"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"
},
@@ -5109,6 +5120,7 @@
"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",
@@ -5151,6 +5163,7 @@
"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",
@@ -5691,6 +5704,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6613,6 +6627,7 @@
"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"
@@ -7915,6 +7930,7 @@
"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",
@@ -10031,9 +10047,10 @@
}
},
"node_modules/mdast-util-to-hast": {
"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==",
"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",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
@@ -12130,6 +12147,7 @@
"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",
@@ -12610,6 +12628,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13465,6 +13484,7 @@
"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"
@@ -14261,6 +14281,7 @@
"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"
}
@@ -14269,6 +14290,7 @@
"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"
},
@@ -14319,6 +14341,7 @@
"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": "*"
},
@@ -14371,6 +14394,7 @@
"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",
@@ -16141,7 +16165,8 @@
"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=="
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"peer": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
@@ -16209,6 +16234,7 @@
"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"
@@ -16526,6 +16552,7 @@
"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",
@@ -16713,6 +16740,7 @@
"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",
@@ -17287,6 +17315,7 @@
"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,6 +4668,11 @@ 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"
@@ -5896,9 +5901,9 @@ mdast-util-phrasing@^4.0.0:
unist-util-is "^6.0.0"
mdast-util-to-hast@^13.0.0:
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==
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==
dependencies:
"@types/hast" "^3.0.0"
"@types/mdast" "^4.0.0"