Fix search command tracking for Lidarr, Readarr and Whisparr v3 (#814)

This commit is contained in:
Flaminel authored and GitHub committed 2026-09-25 17:37:39 +03:00
1 parent c032554eca
commit 2998442db6
11 files changed
+348 -45

No files matched your search

@@ -358,7 +358,7 @@ public class ArrClientTests
status.Status.ShouldBe(ArrCommandState.Completed);
status.Message.ShouldBe("ok");
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command/42");
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command/42");
}
[Fact]
@@ -406,7 +406,7 @@ public class ArrClientTests
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.Method.ShouldBe(HttpMethod.Get);
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command");
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command");
request.Headers.GetValues("x-api-key").ShouldHaveSingleItem().ShouldBe("secret-key");
}
@@ -101,6 +101,40 @@ public class LidarrClientTests
#endregion
#region GetCommandStatusAsync / GetCommandsAsync
[Fact]
public async Task GetCommandStatusAsync_UsesV1CommandPath()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new ArrCommandStatus(42, ArrCommandState.Completed, null))));
// Act
await _client.GetCommandStatusAsync(_arrInstance, 42);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command/42");
}
[Fact]
public async Task GetCommandsAsync_UsesV1CommandPath()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new List<ArrCommandStatus> { new(1, ArrCommandState.Started, null) })));
// Act
await _client.GetCommandsAsync(_arrInstance);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command");
}
#endregion
#region HasContentId
[Fact]
@@ -151,9 +185,9 @@ public class LidarrClientTests
}
[Fact]
public async Task SearchItemsAsync_PostsAlbumSearchCommandWithAllIds_AndReturnsEmpty()
public async Task SearchItemsAsync_PostsAlbumSearchCommandWithAllIds_AndReturnsCommandId()
{
// Arrange — Lidarr's SearchItemsAsync always returns [] regardless of HTTP response
// Arrange
RouteResponses(commandIdForPost: 11);
var items = new HashSet<SearchItem>
{
@@ -165,7 +199,7 @@ public class LidarrClientTests
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
ids.ShouldBe(new long[] { 11 });
var post = _httpMessageHandler.CapturedRequests.Single(r => r.Method == HttpMethod.Post);
post.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command");
var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)];
@@ -174,6 +208,62 @@ public class LidarrClientTests
body!.ShouldContain("\"albumIds\":[10,20]", Case.Insensitive);
}
[Fact]
public async Task SearchItemsAsync_NoCommandIdInResponse_ReturnsEmpty()
{
// Arrange — POST returns 200 with body that has no id (treated as null id)
_httpMessageHandler.SetupResponse((req, _) =>
{
if (req.Method == HttpMethod.Post)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
});
}
return Task.FromResult(JsonNullResponse());
});
var items = new HashSet<SearchItem> { new() { Id = 1 } };
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemsAsync_NullCommandResponseFromDryRun_ReturnsEmpty()
{
// Arrange — interceptor returns null on dry-run
_dryRunInterceptor
.InterceptAsync<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.Returns((HttpResponseMessage?)null);
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse()));
var items = new HashSet<SearchItem> { new() { Id = 5 } };
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemAsync_ReturnsCommandIdInsteadOfThrowing()
{
// Arrange
RouteResponses(commandIdForPost: 77);
// Act
var id = await _client.SearchItemAsync(_arrInstance, new SearchItem { Id = 1 });
// Assert
id.ShouldBe(77);
}
[Fact]
public async Task SearchItemsAsync_ServerError_Throws()
{
@@ -254,6 +254,40 @@ public class RadarrClientTests
#endregion
#region GetCommandStatusAsync / GetCommandsAsync
[Fact]
public async Task GetCommandStatusAsync_UsesV3CommandPath()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new ArrCommandStatus(42, ArrCommandState.Completed, null))));
// Act
await _client.GetCommandStatusAsync(_arrInstance, 42);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command/42");
}
[Fact]
public async Task GetCommandsAsync_UsesV3CommandPath()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new List<ArrCommandStatus> { new(1, ArrCommandState.Started, null) })));
// Act
await _client.GetCommandsAsync(_arrInstance);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command");
}
#endregion
#region StreamAllMoviesAsync / GetAllTagsAsync / GetQualityProfilesAsync / GetMovieFileScoresAsync
[Fact]
@@ -101,6 +101,40 @@ public class ReadarrClientTests
#endregion
#region GetCommandStatusAsync / GetCommandsAsync
[Fact]
public async Task GetCommandStatusAsync_UsesV1CommandPath()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new ArrCommandStatus(42, ArrCommandState.Completed, null))));
// Act
await _client.GetCommandStatusAsync(_arrInstance, 42);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command/42");
}
[Fact]
public async Task GetCommandsAsync_UsesV1CommandPath()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new List<ArrCommandStatus> { new(1, ArrCommandState.Started, null) })));
// Act
await _client.GetCommandsAsync(_arrInstance);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command");
}
#endregion
#region HasContentId
[Fact]
@@ -151,9 +185,9 @@ public class ReadarrClientTests
}
[Fact]
public async Task SearchItemsAsync_PostsBookSearchCommandWithAllIds_AndReturnsEmpty()
public async Task SearchItemsAsync_PostsBookSearchCommandWithAllIds_AndReturnsCommandId()
{
// Arrange — Readarr's SearchItemsAsync always returns [] regardless of HTTP response
// Arrange
RouteResponses(commandIdForPost: 22);
var items = new HashSet<SearchItem>
{
@@ -165,7 +199,7 @@ public class ReadarrClientTests
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
ids.ShouldBe(new long[] { 22 });
var post = _httpMessageHandler.CapturedRequests.Single(r => r.Method == HttpMethod.Post);
post.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command");
var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)];
@@ -174,6 +208,62 @@ public class ReadarrClientTests
body!.ShouldContain("\"BookIds\":[100,200]", Case.Insensitive);
}
[Fact]
public async Task SearchItemsAsync_NoCommandIdInResponse_ReturnsEmpty()
{
// Arrange — POST returns 200 with body that has no id (treated as null id)
_httpMessageHandler.SetupResponse((req, _) =>
{
if (req.Method == HttpMethod.Post)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
});
}
return Task.FromResult(JsonNullResponse());
});
var items = new HashSet<SearchItem> { new() { Id = 1 } };
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemsAsync_NullCommandResponseFromDryRun_ReturnsEmpty()
{
// Arrange — interceptor returns null on dry-run
_dryRunInterceptor
.InterceptAsync<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.Returns((HttpResponseMessage?)null);
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse()));
var items = new HashSet<SearchItem> { new() { Id = 1 } };
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemAsync_ReturnsCommandIdInsteadOfThrowing()
{
// Arrange
RouteResponses(commandIdForPost: 33);
// Act
var id = await _client.SearchItemAsync(_arrInstance, new SearchItem { Id = 1 });
// Assert
id.ShouldBe(33);
}
[Fact]
public async Task SearchItemsAsync_ServerError_Throws()
{
@@ -137,8 +137,8 @@ public class WhisparrV3ClientTests
[Fact]
public async Task SearchItemsAsync_PostsMoviesSearchCommandWithAllIds()
{
// Arrange: GET /movie/{id} calls build the log context, return null so it bails out
_httpMessageHandler.SetupResponse((req, _) => Task.FromResult(JsonNullResponse()));
// Arrange
RouteResponses(commandIdForPost: 22);
HashSet<SearchItem> items = new()
{
new SearchItem { Id = 10 },
@@ -149,7 +149,7 @@ public class WhisparrV3ClientTests
List<long> ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
ids.ShouldBe(new long[] { 22 });
HttpRequestMessage post = _httpMessageHandler.CapturedRequests.Single(r => r.Method == HttpMethod.Post);
post.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command");
string? body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)];
@@ -181,6 +181,62 @@ public class WhisparrV3ClientTests
get.RequestUri!.AbsolutePath.ShouldBe("/api/v3/movie/10");
}
[Fact]
public async Task SearchItemsAsync_NoCommandIdInResponse_ReturnsEmpty()
{
// Arrange — POST returns 200 with body that has no id (treated as null id)
_httpMessageHandler.SetupResponse((req, _) =>
{
if (req.Method == HttpMethod.Post)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
});
}
return Task.FromResult(JsonNullResponse());
});
HashSet<SearchItem> items = new() { new SearchItem { Id = 1 } };
// Act
List<long> ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemsAsync_NullCommandResponseFromDryRun_ReturnsEmpty()
{
// Arrange — interceptor returns null on dry-run
_dryRunInterceptor
.InterceptAsync<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.Returns((HttpResponseMessage?)null);
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse()));
HashSet<SearchItem> items = new() { new SearchItem { Id = 1 } };
// Act
List<long> ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemAsync_ReturnsCommandIdInsteadOfThrowing()
{
// Arrange
RouteResponses(commandIdForPost: 33);
// Act
long id = await _client.SearchItemAsync(_arrInstance, new SearchItem { Id = 1 });
// Assert
id.ShouldBe(33);
}
#endregion
[Fact]
@@ -251,6 +307,19 @@ public class WhisparrV3ClientTests
#region Helpers
private void RouteResponses(long commandIdForPost)
{
_httpMessageHandler.SetupResponse((req, _) =>
{
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.EndsWith("/command"))
{
return Task.FromResult(JsonResponse(new { id = commandIdForPost }));
}
return Task.FromResult(JsonNullResponse());
});
}
private static QueueRecord BuildRecord(long id) => new()
{
Id = id,
@@ -32,10 +32,9 @@ public abstract class ArrClient : IArrClient
protected const int QueuePageSize = 200;
/// <summary>
/// Command API path.
/// Every client uses v3 here, whatever its ApiVersion.
/// Command API path for this app's API version.
/// </summary>
protected const string CommandUrlPath = "/api/v3/command";
protected string CommandUrlPath => $"/api/{ApiVersion}/command";
/// <summary>
/// REST API version segment for this app, e.g. "v3".
@@ -52,8 +52,10 @@ public class LidarrClient : ArrClient, ILidarrClient
return [];
}
List<long> commandIds = [];
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/command";
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}{CommandUrlPath}";
foreach (var command in GetSearchCommands(items))
{
@@ -69,19 +71,28 @@ public class LidarrClient : ArrClient, ILidarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
using HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
if (response is not null)
{
long? commandId = await ReadCommandIdAsync(response);
if (commandId.HasValue)
{
commandIds.Add(commandId.Value);
}
}
_logger.LogInformation("{Log}", GetSearchLog(arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
_logger.LogError("{Log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
}
return [];
return commandIds;
}
public override bool HasContentId(QueueRecord record) => record.ArtistId is not 0 && record.AlbumId is not 0;
@@ -77,7 +77,7 @@ public class RadarrClient : ArrClient, IRadarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
using HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
if (response is null)
{
@@ -85,15 +85,14 @@ public class RadarrClient : ArrClient, IRadarrClient
}
long? commandId = await ReadCommandIdAsync(response);
response.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
_logger.LogInformation("{Log}", GetSearchLog(arrInstance.Url, command, true, logContext));
return commandId.HasValue ? [commandId.Value] : [];
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
_logger.LogError("{Log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
}
@@ -53,16 +53,16 @@ public class ReadarrClient : ArrClient, IReadarrClient
}
List<long> ids = items.Select(item => item.Id).ToList();
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/command";
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}{CommandUrlPath}";
ReadarrCommand command = new()
{
Name = "BookSearch",
BookIds = ids,
};
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonSerializer.Serialize(command, CleanuparrJsonOptions.Outbound),
@@ -75,18 +75,24 @@ public class ReadarrClient : ArrClient, IReadarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
using HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
long? commandId = null;
if (response is not null)
{
commandId = await ReadCommandIdAsync(response);
}
_logger.LogInformation("{Log}", GetSearchLog(arrInstance.Url, command, true, logContext));
return commandId.HasValue ? [commandId.Value] : [];
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
_logger.LogError("{Log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
return [];
}
public override bool HasContentId(QueueRecord record) => record.AuthorId is not 0 && record.BookId is not 0;
@@ -75,12 +75,11 @@ public class SonarrClient : ArrClient, ISonarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
using HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
if (response is not null)
{
long? commandId = await ReadCommandIdAsync(response);
response.Dispose();
if (commandId.HasValue)
{
@@ -77,18 +77,24 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
using HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
long? commandId = null;
if (response is not null)
{
commandId = await ReadCommandIdAsync(response);
}
_logger.LogInformation("{Log}", GetSearchLog(arrInstance.Url, command, true, logContext));
return commandId.HasValue ? [commandId.Value] : [];
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
_logger.LogError("{Log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
return [];
}
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;