Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions listenarr.api/Features/Search/SearchController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,17 @@ public async Task<ActionResult<AudibleSearchResponse>> 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)
Expand Down
13 changes: 12 additions & 1 deletion listenarr.application/Metadata/Audible/AudibleMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AudibleSearchResult>? Results { get; set; } public int? TotalResults { get; set; } }
public class AudibleSearchResponse
{
public List<AudibleSearchResult>? Results { get; set; }
public int? TotalResults { get; set; }

/// <summary>
/// True when Audible did not answer, so an empty <see cref="Results"/> 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.
/// </summary>
public bool ProviderUnavailable { get; set; }
}

public class AudibleSearchResult
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ public async Task<SearchProductsDirectResponse> 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) ||
Expand Down Expand Up @@ -220,7 +223,10 @@ private async Task<SearchProductsDirectResponse> 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;
Expand Down Expand Up @@ -254,7 +260,8 @@ private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectRespon
return new AudibleSearchResponse
{
Results = response.Results,
TotalResults = response.TotalResults
TotalResults = response.TotalResults,
ProviderUnavailable = response.ProviderUnavailable
};
}

Expand Down
3 changes: 2 additions & 1 deletion listenarr.application/Metadata/Audible/AudibleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,8 @@ private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectRespon
return new AudibleSearchResponse
{
Results = response.Results,
TotalResults = response.TotalResults
TotalResults = response.TotalResults,
ProviderUnavailable = response.ProviderUnavailable
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,15 @@ internal sealed class SearchProductsDirectResponse
public List<AudibleSearchResult> Results { get; set; } = new();
public int TotalResults { get; set; }
public List<JsonElement>? RawProducts { get; set; }

/// <summary>
/// Audible did not answer, so an empty <see cref="Results"/> 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.
/// </summary>
public bool ProviderUnavailable { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<AudibleBookResponse?>(null),
NullLogger.Instance);
}

/// <summary>Never answers inside the call's own timeout, which is what a real timeout looks like.</summary>
private sealed class StallingHandler : HttpMessageHandler
{
public int Requests { get; private set; }

protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests++;
await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken);
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
}
}

/// <summary>Answers promptly, with a catalogue that genuinely holds nothing.</summary>
private sealed class EmptyCatalogHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(new { products = Array.Empty<object>() }))
});
}
}
}