Compare commits

..

5 Commits

Author SHA1 Message Date
Flaminel
530ddf01f0 added tests 2026-01-02 03:21:24 +02:00
Flaminel
e465582a01 fixed tests 2026-01-01 23:14:27 +02:00
Flaminel
01238759b6 fixed typo 2026-01-01 21:55:14 +02:00
Flaminel
acb98db17f added migration 2026-01-01 21:03:07 +02:00
Flaminel
8c16a8b9dd added support for Whisparr v3 2026-01-01 21:03:01 +02:00
51 changed files with 2256 additions and 238 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -122,7 +122,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -182,7 +182,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -199,8 +199,8 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), Times.Once);
}
#endregion
@@ -222,7 +222,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -277,7 +277,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -334,7 +334,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -391,7 +391,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -466,7 +466,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -535,7 +535,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -603,7 +603,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -677,7 +677,7 @@ public class QueueCleanerTests : IDisposable
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -746,7 +746,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr))
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -835,7 +835,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr))
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -907,7 +907,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Lidarr))
.Setup(x => x.GetClient(InstanceType.Lidarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -979,7 +979,7 @@ public class QueueCleanerTests : IDisposable
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Readarr))
.Setup(x => x.GetClient(InstanceType.Readarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
@@ -1040,5 +1040,240 @@ 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,16 +197,17 @@ 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)
public static ArrInstance AddWhisparrInstance(DataContext context, string url = "http://whisparr:6969", bool enabled = true, float version = 2)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Whisparr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Whisparr",
Name = $"Test Whisparr v{version}",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
Version = version,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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