diff --git a/listenarr.api/Features/Search/SearchController.cs b/listenarr.api/Features/Search/SearchController.cs index 6f92322e7..fd3fdf72b 100644 --- a/listenarr.api/Features/Search/SearchController.cs +++ b/listenarr.api/Features/Search/SearchController.cs @@ -350,6 +350,11 @@ public async Task> TestApiConnection(string apiId) /// Search the Audible catalog for audiobooks. /// [HttpGet("audible")] + [ProducesResponseType(typeof(AudibleSearchResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> SearchAudible( [FromQuery] string query, [FromQuery] string region = "us", @@ -368,6 +373,17 @@ public async Task> SearchAudible( return NotFound("No results found"); } + // Audible did not answer. Returning the empty result would be a 200 that + // says "this book is not in the catalogue", which is a different claim and + // one a caller acts on differently. 503 says try again instead. + if (result.ProviderUnavailable) + { + _logger.LogWarning( + "Audible did not answer for query: {Query}; reporting unavailable rather than zero matches", + LogRedaction.SanitizeText(query)); + return StatusCode(StatusCodes.Status503ServiceUnavailable, "The Audible catalog did not respond. This is not a confirmed zero-match; retry shortly."); + } + return Ok(result); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) diff --git a/listenarr.application/Metadata/Audible/AudibleMetadata.cs b/listenarr.application/Metadata/Audible/AudibleMetadata.cs index 80e60168c..2107f14c0 100644 --- a/listenarr.application/Metadata/Audible/AudibleMetadata.cs +++ b/listenarr.application/Metadata/Audible/AudibleMetadata.cs @@ -31,7 +31,18 @@ public class AudibleNarrator { public string? Name { get; set; } } public class AudibleGenre { public string? Asin { get; set; } public string? Name { get; set; } public string? Type { get; set; } } public class AudibleSeries { public string? Asin { get; set; } public string? Name { get; set; } public string? Position { get; set; } } - public class AudibleSearchResponse { public List? Results { get; set; } public int? TotalResults { get; set; } } + public class AudibleSearchResponse + { + public List? Results { get; set; } + public int? TotalResults { get; set; } + + /// + /// True when Audible did not answer, so an empty means the + /// lookup failed rather than the catalogue having nothing. Callers that treat an + /// empty list as "this book does not exist" need to check this first. + /// + public bool ProviderUnavailable { get; set; } + } public class AudibleSearchResult { diff --git a/listenarr.application/Metadata/Audible/AudibleProductSearchWorkflow.cs b/listenarr.application/Metadata/Audible/AudibleProductSearchWorkflow.cs index c98c34222..d5adb32b6 100644 --- a/listenarr.application/Metadata/Audible/AudibleProductSearchWorkflow.cs +++ b/listenarr.application/Metadata/Audible/AudibleProductSearchWorkflow.cs @@ -173,7 +173,10 @@ public async Task SearchProductsDirectAsync( query, title, author, narrator, publisher, page, limit, safeRegion, language, sortBy, returnRawProducts); - if (result.Results.Count == 0) + // A failed call returns zero results, so without the check the diacritics + // retry fires against an Audible that just timed out and spends the caller's + // remaining budget on a second request that fails the same way. + if (result.Results.Count == 0 && !result.ProviderUnavailable) { var hasDiacritics = HasDiacritics(query) || HasDiacritics(title) || @@ -220,7 +223,10 @@ private async Task SearchProductsCoreAsync( using var doc = await _apiClient.GetJsonDocumentAsync(url, safeRegion, includeLocaleHeaders: false, timeoutSeconds: 10); if (doc == null) { - return new SearchProductsDirectResponse(); + // The client already logged why. What matters here is not losing the fact + // that it failed: an empty response with no marker is indistinguishable + // from Audible answering "no such book". + return new SearchProductsDirectResponse { ProviderUnavailable = true }; } var root = doc.RootElement; @@ -254,7 +260,8 @@ private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectRespon return new AudibleSearchResponse { Results = response.Results, - TotalResults = response.TotalResults + TotalResults = response.TotalResults, + ProviderUnavailable = response.ProviderUnavailable }; } diff --git a/listenarr.application/Metadata/Audible/AudibleService.cs b/listenarr.application/Metadata/Audible/AudibleService.cs index 9b56f5aca..a3ef095d5 100644 --- a/listenarr.application/Metadata/Audible/AudibleService.cs +++ b/listenarr.application/Metadata/Audible/AudibleService.cs @@ -289,7 +289,8 @@ private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectRespon return new AudibleSearchResponse { Results = response.Results, - TotalResults = response.TotalResults + TotalResults = response.TotalResults, + ProviderUnavailable = response.ProviderUnavailable }; } diff --git a/listenarr.application/Metadata/Audible/SearchProductsDirectResponse.cs b/listenarr.application/Metadata/Audible/SearchProductsDirectResponse.cs index 6d60a6867..39af2570d 100644 --- a/listenarr.application/Metadata/Audible/SearchProductsDirectResponse.cs +++ b/listenarr.application/Metadata/Audible/SearchProductsDirectResponse.cs @@ -25,5 +25,15 @@ internal sealed class SearchProductsDirectResponse public List Results { get; set; } = new(); public int TotalResults { get; set; } public List? RawProducts { get; set; } + + /// + /// Audible did not answer, so an empty means "not known" + /// rather than "not in the catalogue". + /// + /// Without this the two are the same object. A per-call timeout returns an empty + /// response that is byte for byte what a genuine zero-match produces, and every + /// caller downstream has to guess. + /// + public bool ProviderUnavailable { get; set; } } } diff --git a/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs b/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs new file mode 100644 index 000000000..d38f3985d --- /dev/null +++ b/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs @@ -0,0 +1,83 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Tests.Features.Api.Features.Search +{ + /// + /// The flag only earns its keep if the endpoint acts on it. These cover the two answers + /// GET /search/audible has to keep apart: a failed lookup and a catalogue that really + /// holds nothing. Without the second one, a 503 on every empty result would pass. + /// + [Trait("Name", "SearchControllerAudibleUnavailableTests")] + [Trait("Category", "Api")] + public class SearchControllerAudibleUnavailableTests : BaseTests + { + [Fact] + public async Task SearchAudible_WhenTheProviderDidNotAnswer_Returns503() + { + var controller = BuildController(new AudibleSearchResponse + { + Results = new List(), + TotalResults = 0, + ProviderUnavailable = true + }); + + var result = await controller.SearchAudible("dune"); + + var status = Assert.IsType(result.Result); + Assert.Equal(503, status.StatusCode); + } + + [Fact] + public async Task SearchAudible_WhenTheCatalogueAnsweredWithNothing_Returns200() + { + var controller = BuildController(new AudibleSearchResponse + { + Results = new List(), + TotalResults = 0 + }); + + var result = await controller.SearchAudible("dune"); + + Assert.IsType(result.Result); + } + + private static SearchController BuildController(AudibleSearchResponse response) + { + var controller = new SearchController( + Mock.Of(), + Mock.Of>(), + new StubAudibleService(response), + Mock.Of()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext() + }; + return controller; + } + + private sealed class StubAudibleService(AudibleSearchResponse response) : AudibleService(new HttpClient(), Mock.Of>()) + { + public override Task SearchBooksAsync( + string query, int page = 1, int limit = 50, string region = "us", string? language = null) + => Task.FromResult(response); + } + } +} diff --git a/tests/Features/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs b/tests/Features/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs new file mode 100644 index 000000000..cad50df54 --- /dev/null +++ b/tests/Features/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using Listenarr.Tests.Common; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Application.Metadata.Audible; + +/// +/// A timeout and a genuine zero-match both produce an empty result set. These assert the +/// two can still be told apart afterwards, which is the whole point: a caller that reads +/// an empty list as "this book is not in the catalogue" is wrong half the time otherwise. +/// +[Trait("Name", "AudibleProviderUnavailableTests")] +[Trait("Category", "Application")] +public sealed class AudibleProviderUnavailableTests : BaseTests +{ + [Fact] + public async Task SearchProductsDirectAsync_WhenAudibleDoesNotAnswer_MarksTheResultUnavailable() + { + var workflow = BuildWorkflow(new StallingHandler()); + + var result = await workflow.SearchProductsDirectAsync( + query: "any", title: null, author: null, narrator: null, publisher: null, + page: 1, limit: 10, region: "us", language: null, sortBy: "Relevance"); + + Assert.True(result.ProviderUnavailable); + Assert.Empty(result.Results); + } + + [Fact] + public async Task SearchProductsDirectAsync_WhenAudibleAnswersWithNothing_IsAConfirmedZeroMatch() + { + // The control for the test above. If ProviderUnavailable were set unconditionally + // on any empty result, this would fail, and the flag would mean nothing. + var workflow = BuildWorkflow(new EmptyCatalogHandler()); + + var result = await workflow.SearchProductsDirectAsync( + query: "any", title: null, author: null, narrator: null, publisher: null, + page: 1, limit: 10, region: "us", language: null, sortBy: "Relevance"); + + Assert.False(result.ProviderUnavailable); + Assert.Empty(result.Results); + } + + [Fact] + public async Task SearchProductsDirectAsync_WhenAudibleDoesNotAnswer_DoesNotSpendTheBudgetOnADiacriticsRetry() + { + // A failed call returns zero results, which used to look exactly like a miss worth + // retrying without diacritics. That second request fails the same way and costs the + // caller another full timeout. + var handler = new StallingHandler(); + var workflow = BuildWorkflow(handler); + + await workflow.SearchProductsDirectAsync( + query: null, title: "Les Mis\u00e9rables", author: null, + narrator: null, publisher: null, + page: 1, limit: 10, region: "us", language: null, sortBy: "Relevance"); + + Assert.Equal(1, handler.Requests); + } + + [Theory] + [InlineData(500)] + [InlineData(503)] + [InlineData(429)] + public async Task SearchProductsDirectAsync_WhenAudibleRejectsTheCall_MarksTheResultUnavailable(int statusCode) + { + // A timeout is only one of the ways the call fails. A 5xx and a rate-limit answer are + // just as much "not known" as "not in the catalogue", and each of them also arrives as + // an empty result set. Only the timeout was covered, so a change to the client that + // turned a 500 into an empty document would have gone unnoticed. + var workflow = BuildWorkflow(new StatusCodeHandler((System.Net.HttpStatusCode)statusCode)); + + var result = await workflow.SearchProductsDirectAsync( + query: "any", title: null, author: null, narrator: null, publisher: null, + page: 1, limit: 10, region: "us", language: null, sortBy: "Relevance"); + + Assert.True(result.ProviderUnavailable); + Assert.Empty(result.Results); + } + + [Fact] + public async Task SearchProductsDirectAsync_WhenTheBodyWillNotParse_MarksTheResultUnavailable() + { + var workflow = BuildWorkflow(new MalformedBodyHandler()); + + var result = await workflow.SearchProductsDirectAsync( + query: "any", title: null, author: null, narrator: null, publisher: null, + page: 1, limit: 10, region: "us", language: null, sortBy: "Relevance"); + + Assert.True(result.ProviderUnavailable); + Assert.Empty(result.Results); + } + + [Fact] + public async Task SearchProductsDirectAsync_WhenTheRequestNeverLeaves_MarksTheResultUnavailable() + { + // Name resolution and connection refusal both surface as HttpRequestException. + var workflow = BuildWorkflow(new TransportFailureHandler()); + + var result = await workflow.SearchProductsDirectAsync( + query: "any", title: null, author: null, narrator: null, publisher: null, + page: 1, limit: 10, region: "us", language: null, sortBy: "Relevance"); + + Assert.True(result.ProviderUnavailable); + Assert.Empty(result.Results); + } + + private static AudibleProductSearchWorkflow BuildWorkflow(HttpMessageHandler handler) + { + var client = new AudibleApiClient(new HttpClient(handler), NullLogger.Instance); + return new AudibleProductSearchWorkflow( + client, + (_, _, _, _) => Task.FromResult(null), + NullLogger.Instance); + } + + /// Never answers inside the call's own timeout, which is what a real timeout looks like. + private sealed class StallingHandler : HttpMessageHandler + { + public int Requests { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests++; + await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + } + } + + /// Answers promptly, and refuses. + private sealed class StatusCodeHandler(System.Net.HttpStatusCode statusCode) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(statusCode)); + } + + /// Answers 200 with something that is not JSON. + private sealed class MalformedBodyHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("{\"products\": [ truncated") + }); + } + } + + /// Never reaches Audible at all. + private sealed class TransportFailureHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => throw new HttpRequestException("Name or service not known"); + } + + /// Answers promptly, with a catalogue that genuinely holds nothing. + private sealed class EmptyCatalogHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new { products = Array.Empty() })) + }); + } + } +}