From 26df9737cb274f80d0aa740ccd25fb7eac78527f Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:52:14 -0500 Subject: [PATCH 1/4] fix(audible): tell a failed catalog lookup apart from a confirmed zero-match An Audible call that times out comes back as an empty result set that is identical to Audible answering "no such book". #877 has the trace: the per-call CancellationTokenSource fires, AudibleApiClient logs a warning and returns null, and SearchProductsCoreAsync turns that null into new SearchProductsDirectResponse(). From there nothing can tell the two apart, and a caller reasonably reads an empty list as an absence. This is the half of #877 that does not depend on its open questions. The retry half is deliberately left alone: it interacts with #635's planned refactor of AudibleApiClient, and the durations need Audible latency figures I do not have. Worth saying because the obvious patch is wrong: adding .Or() to the existing Polly policy does not retry these. AudibleApiClient passes its own token into SendAsync and PolicyHttpMessageHandler hands that token to policy.ExecuteAsync, so the retry engine holds an already-cancelled token. Measured at the message handler in #877, not reasoned about. Nothing in this PR touches Polly. SearchProductsDirectResponse gains ProviderUnavailable, set where the null is currently discarded. It rides through both ToSearchResponse copies onto AudibleSearchResponse, which is additive, so a client that ignores the field sees what it saw before. The /search/audible endpoint answers 503 rather than a 200 that makes a false claim about the catalogue. A flag rather than an exception, deliberately. Throwing from SearchProductsCoreAsync would reach fifteen call sites across series lookup, author-catalog paging and several fallback cascades inside this same workflow, all of which currently degrade to an empty page and carry on. That is a much larger behaviour change than the contract fix needs, and not one to make while #635 is open. One thing fell out of reading it. The diacritics retry at SearchProductsDirectAsync fires on any empty result, so a timeout spent the caller's remaining budget on a second request that failed the same way. It now checks the flag first. NOT fixed here: POST /api/v1/search still collapses the two, because carrying the signal through SearchService.IntelligentSearchAsync means changing its return type. That is the endpoint the report in #877 came from, so this is a partial answer to it and I would rather say so than imply otherwise. Tests: the failure case, a control on a promptly-answered empty catalogue so the flag cannot be set unconditionally, and the diacritics budget. Each was checked by reverting its own half. The first version of the diacritics test was worthless, passing with the guard removed because the query I picked had no diacritics in it; it uses an accented title now. Full suite 3121 passed, 0 failed, 130 skipped. --- .../Features/Search/SearchController.cs | 11 +++ .../Metadata/Audible/AudibleMetadata.cs | 13 ++- .../Audible/AudibleProductSearchWorkflow.cs | 13 ++- .../Metadata/Audible/AudibleService.cs | 3 +- .../Audible/SearchProductsDirectResponse.cs | 10 ++ .../AudibleProviderUnavailableTests.cs | 97 +++++++++++++++++++ 6 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 tests/Features/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs diff --git a/listenarr.api/Features/Search/SearchController.cs b/listenarr.api/Features/Search/SearchController.cs index 6f92322e7..371a10ede 100644 --- a/listenarr.api/Features/Search/SearchController.cs +++ b/listenarr.api/Features/Search/SearchController.cs @@ -368,6 +368,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(503, "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/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs b/tests/Features/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs new file mode 100644 index 000000000..e78c3f26f --- /dev/null +++ b/tests/Features/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using Listenarr.Application.Metadata.Audible; +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); + } + + 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, 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() })) + }); + } + } +} From 16f8b0bde8d42f15fb26d0a5de8b80aafe731cfe Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:43:53 -0500 Subject: [PATCH 2/4] test(audible): cover the other ways the catalog lookup fails, and the 503 itself The change distinguishes a failed lookup from a confirmed zero-match, but only the timeout was tested. A refusal and a body that will not parse reach ProviderUnavailable by the same route, through GetJsonDocumentAsync returning null, and nothing pinned that. A change there which turned a 500 into an empty document would have put the original bug back with the suite still green. Four more at the workflow: 500, 503 and 429, a truncated body, and a transport failure standing in for name resolution. Each fails with the flag reverted; the prompt empty catalogue still passes, so they are not just asserting the flag is always set. Two at the controller for the answer the body claims. GET /search/audible had no test at all for the 503, so the sentence describing it rested on reading. The second one holds the endpoint to 200 when the catalogue genuinely answered with nothing, which is the case a blanket 503 would break. Also drops a using of Listenarr.Application.Metadata.Audible that is already a global using in the test project, which was raising IDE0005 on every build. Co-Authored-By: Claude Fable 5.1 --- ...SearchControllerAudibleUnavailableTests.cs | 80 +++++++++++++++++++ .../AudibleProviderUnavailableTests.cs | 77 +++++++++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs diff --git a/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs b/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs new file mode 100644 index 000000000..e6d471714 --- /dev/null +++ b/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs @@ -0,0 +1,80 @@ +/* + * 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 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. + /// + public class SearchControllerAudibleUnavailableTests + { + [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 index e78c3f26f..cad50df54 100644 --- a/tests/Features/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs +++ b/tests/Features/Application/Metadata/Audible/AudibleProviderUnavailableTests.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Listenarr.Application.Metadata.Audible; using Listenarr.Tests.Common; using Microsoft.Extensions.Logging.Abstractions; @@ -59,6 +58,53 @@ await workflow.SearchProductsDirectAsync( 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); @@ -82,6 +128,35 @@ protected override async Task SendAsync( } } + /// 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 { From bd33711da611c2295887a1b2adf75ebc8a3c5ed8 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:47:34 -0500 Subject: [PATCH 3/4] test(audible): give the new controller class the repository's test conventions BackendArchitectureTests.TestClasses_FollowRepositoryConventions requires every test class to inherit BaseTests and to carry an exact Name trait plus a non-empty Category. The class added in the previous commit had neither, so the full suite failed on it. Co-Authored-By: Claude Fable 5.1 --- .../Search/SearchControllerAudibleUnavailableTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs b/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs index e6d471714..d38f3985d 100644 --- a/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs +++ b/tests/Features/Api/Features/Search/SearchControllerAudibleUnavailableTests.cs @@ -15,6 +15,7 @@ * 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 @@ -24,7 +25,9 @@ namespace Listenarr.Tests.Features.Api.Features.Search /// 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. /// - public class SearchControllerAudibleUnavailableTests + [Trait("Name", "SearchControllerAudibleUnavailableTests")] + [Trait("Category", "Api")] + public class SearchControllerAudibleUnavailableTests : BaseTests { [Fact] public async Task SearchAudible_WhenTheProviderDidNotAnswer_Returns503() From 8be7ae560225b8024fc355b7cc55ffc89caeef2d Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:57:59 -0500 Subject: [PATCH 4/4] fix(audible): declare the search endpoint's responses, including the new 503 Every other endpoint in SearchController carries ProducesResponseType for the codes it can return. GET /search/audible carried none, so the 503 this change introduces was invisible in the Swagger surface that the frontend and any Readarr-shaped client generate from. The numeric literal becomes the StatusCodes constant to match its neighbours. Documentation only. No runtime behaviour changes. Co-Authored-By: Claude Fable 5.1 --- listenarr.api/Features/Search/SearchController.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/listenarr.api/Features/Search/SearchController.cs b/listenarr.api/Features/Search/SearchController.cs index 371a10ede..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", @@ -376,7 +381,7 @@ public async Task> SearchAudible( _logger.LogWarning( "Audible did not answer for query: {Query}; reporting unavailable rather than zero matches", LogRedaction.SanitizeText(query)); - return StatusCode(503, "The Audible catalog did not respond. This is not a confirmed zero-match; retry shortly."); + return StatusCode(StatusCodes.Status503ServiceUnavailable, "The Audible catalog did not respond. This is not a confirmed zero-match; retry shortly."); } return Ok(result);